From 6c9e8b166cc7a9cbd24f4903f1de059ace79600c Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 7 Jun 2026 17:55:57 -0700 Subject: [PATCH 01/11] feat: HMR dev-sessions, ESM resolver hardening, dev-mode runtime globals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Hot Module Replacement runtime layer plus the supporting ESM resolver hardening and dev-session globals that make hot reload viable on Android. * `import.meta.hot`: `data`, `accept`, `dispose`, `prune`, `decline`, `invalidate`, `on`/`off`/`send` event surface. * Dev-session globals (`__nsStartDevSession`, `__nsReloadDevApp`, `__nsInvalidateModules`, `__nsRunHmrDispose`, `__nsRunHmrPrune`, `__nsHasDeclinedModule`, `__nsKickstartHmrPrefetch`, `__nsGetLoadedModuleUrls`, `__nsApplyStyleUpdate`, `__nsConfigureDevRuntime`/`__nsConfigureRuntime`, `__nsTerminateAllWorkers`). * Speculative HTTP module prefetch (opt-in) with canonical-key normalization so `__ns_hmr__/v` and `__ns_boot__/b` tag prefixes share `hot.data` identity across reload cycles. * ESM resolver hardening in `ModuleInternalCallbacks.cpp` to: - Preserve synthetic-namespace identity (`ns-vendor://`, `optional:`, `node:`, `blob:`) — these are NOT filesystem paths. - Handle HTTP/HTTPS module URLs end-to-end (resolution, fetch, canonical-key collapse, dynamic import). - Compile `.json` imports into synthetic ES modules. * Android runtime-dex support for `.extend()` classes created during HMR that the static binding generator can't see at build time: runtime DEX generation with `$`/`_` inner-class normalization (`DexFactory`), dev/HMR class-resolution fallback (`ClassResolver`), and dev-flag / `logScriptLoading` plumbing (`AppConfig`, `DevFlags`). --- README.md | 109 +- test-app/app/src/main/assets/app/mainpage.js | 2 + test-app/app/src/main/assets/app/shared | 2 +- .../assets/app/tests/esm/hmr/hot-data-ext.js | 79 + .../assets/app/tests/esm/hmr/hot-data-ext.mjs | 79 + .../assets/app/tests/testHmrHotDataExt.mjs | 65 + .../testNodeBuiltinsAndOptionalModules.mjs | 127 + .../runtime/src/main/cpp/CallbackHandlers.cpp | 16 + .../runtime/src/main/cpp/CallbackHandlers.h | 10 + test-app/runtime/src/main/cpp/DevFlags.cpp | 81 +- test-app/runtime/src/main/cpp/DevFlags.h | 20 + test-app/runtime/src/main/cpp/HMRSupport.cpp | 2749 ++++++++++++++++- test-app/runtime/src/main/cpp/HMRSupport.h | 363 ++- .../runtime/src/main/cpp/MetadataNode.cpp | 75 +- .../runtime/src/main/cpp/ModuleInternal.cpp | 137 +- .../src/main/cpp/ModuleInternalCallbacks.cpp | 1034 ++++++- .../src/main/cpp/ModuleInternalCallbacks.h | 57 + test-app/runtime/src/main/cpp/Runtime.cpp | 266 +- test-app/runtime/src/main/cpp/URLImpl.cpp | 89 + test-app/runtime/src/main/cpp/URLImpl.h | 9 + .../runtime/src/main/cpp/WorkerWrapper.cpp | 4 +- test-app/runtime/src/main/cpp/WorkerWrapper.h | 3 +- .../src/main/java/com/tns/AppConfig.java | 20 +- .../src/main/java/com/tns/ClassResolver.java | 31 +- .../src/main/java/com/tns/DexFactory.java | 57 +- .../src/main/java/com/tns/Runtime.java | 27 + 26 files changed, 5256 insertions(+), 255 deletions(-) create mode 100644 test-app/app/src/main/assets/app/tests/esm/hmr/hot-data-ext.js create mode 100644 test-app/app/src/main/assets/app/tests/esm/hmr/hot-data-ext.mjs create mode 100644 test-app/app/src/main/assets/app/tests/testHmrHotDataExt.mjs create mode 100644 test-app/app/src/main/assets/app/tests/testNodeBuiltinsAndOptionalModules.mjs diff --git a/README.md b/README.md index b41749a16..4ec524aad 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Contains the source code for the NativeScript's Android Runtime. [NativeScript]( - [Main Projects](#main-projects) - [Helper Projects](#helper-projects) +- [SBG vs Runtime Dex Generation](#sbg-vs-runtime-dex-generation) - [Architecture Diagram](#architecture-diagram) - [Build Prerequisites](#build-prerequisites) - [How to build](#how-to-build) @@ -29,9 +30,115 @@ The repo is structured in the following projects (ordered by dependencies): ## Helper Projects -* [**android-static-binding-generator**](android-static-binding-generator) - build tool that generates bindings based on the user's javascript code. +* [**android-static-binding-generator**](android-static-binding-generator) - build tool that generates bindings based on the user's javascript code. See [SBG vs Runtime Dex Generation](#sbg-vs-runtime-dex-generation) for the production vs HMR-dev split. * [**project-template**](build-artifacts/project-template-gradle) - this is an empty placeholder Android Application project, used by the [NativeScript CLI](https://github.com/NativeScript/nativescript-cli) when building an Android project. +## SBG vs Runtime Dex Generation + +The Android runtime turns every JavaScript `.extend('com.tns.Foo', Bar, { ... })` +(or `Bar.extend({ ... })`) call into a real Java subclass with a dispatching +proxy. There are **two** code paths that produce that subclass — a build-time +path and a runtime path — and which one runs depends on whether the +`.extend(...)` call site was visible to the Static Binding Generator (SBG) +when the APK was built. + +### Build-time path (production / classic CLI) + +[**android-static-binding-generator**](android-static-binding-generator) (SBG) +runs over the bundled JS once during `nativescript build android`: + +1. SBG parses every JS file the bundle includes and finds every + `.extend('com.tns.X', BaseClass, { ... })` call (and the modern + `BaseClass.extend({ ... })` shorthand). +2. For each call it asks + [**android-binding-generator**](test-app/runtime-binding-generator)'s + `ProxyGenerator`/`Dump` to emit a `.dex` for a Java class named + `com.tns.gen.` (or, for `@JavaProxy(...)`-style + explicit names, the user-chosen name). +3. The resulting dex files are packaged into the APK alongside the JS + bundle and listed in metadata so the runtime can find them via the + classloader. + +In production this means the Java class is already present and loadable +the very first time the JS `.extend(...)` call runs. The runtime never +generates a fresh dex. + +### Runtime path (HMR-dev / dynamic `.extend`) + +When SBG can't see the call at build time, the runtime generates the dex +on demand. This happens in two common shapes: + +* **HMR/Vite dev workflow** — the build-time bundle (`bundle.mjs`) is just + the HMR bootstrap; modules like + `@nativescript/core/ui/frame/fragment.android.ts` are fetched over HTTP + at runtime. SBG never sees those `.extend(...)` calls, so no pre-baked + dex exists. +* **`unnamed-extend` use cases** — `eval`-generated extends, or extends + whose first argument is computed at runtime, escape the SBG scan even + in production builds. + +The runtime path is wired through +[`com.tns.ClassResolver`](test-app/runtime/src/main/java/com/tns/ClassResolver.java) +→ [`com.tns.DexFactory`](test-app/runtime/src/main/java/com/tns/DexFactory.java): + +1. `ClassResolver.resolveClass` first tries `classStorageService.retrieveClass(name)`. + In production this hits the SBG-generated dex and we're done. +2. On `LookedUpClassNotFound` (typical for HMR), if a `baseClassName` is + present, `ClassResolver` falls back to `DexFactory.resolveClass(...)` + which runs the same `ProxyGenerator`/`Dump` pipeline SBG uses — only + it does it at runtime, writes the dex into the app's per-thumb cache + under `/.dex`, wraps it in a `.jar`, and loads it via + `DexClassLoader` (or `BaseDexClassLoader` injection when the + `injectIntoParentClassLoader` flag is on). +3. Generated proxy class names normalize JVM inner-class `$` to `_` + (both in `Dump`'s class signature and in `DexFactory`'s + `loadClass(...)` arg). The actual JVM lookup name is always + `com.tns.gen.` — never `com.tns.gen.$`. + +### Edge cases worth knowing about + +* **`$` vs `_` mismatches** — `Class.forName(baseClassName)` requires JVM + `$` inner-class syntax (`android.app.Application$ActivityLifecycleCallbacks`), + but `classLoader.loadClass(generatedName)` requires the `_`-normalized + sibling (`com.tns.gen.android.app.Application_ActivityLifecycleCallbacks`). + Both `DexFactory.resolveClass` and `DexFactory.findClass` apply the + normalization in the loadable-name path while preserving `$` for the + reflective base-class lookup. Removing either normalization + reintroduces the "Didn't find class + `com.tns.gen.android.app.Application$ActivityLifecycleCallbacks`" + ClassNotFoundException for runtime-generated proxies. +* **Cache invalidation** — the runtime dex cache is keyed on a per-build + `dexThumb` (the runtime regenerates it whenever the JS bundle changes). + Stale dex from a previous boot is purged in + `DexFactory.updateDexThumbAndPurgeCache`. Don't bypass the thumb — a + dex generated against an older base class can crash at method dispatch + time when the base API drifts. +* **Parent-classloader injection** — Android framework code that calls + `Class.forName(name)` searches the app's `PathClassLoader`, *not* an + isolated `DexClassLoader`. When a generated proxy needs to be visible + to framework reflection (e.g. `FragmentFactory`, `Activity` resolution + from `AndroidManifest.xml`), construct the `DexFactory` with + `injectIntoParentClassLoader=true` so its `injectDexIntoClassLoader` + helper splices the generated jar's `dexElements` onto the parent's + `pathList`. +* **Don't normalize synthetic module keys** — module registry keys like + `ns-vendor://...`, `optional:...`, `node:...`, `blob:...` are not + filesystem paths and must be preserved verbatim through invalidation + + reload. They are NOT the same kind of "synthetic name" as the + `com.tns.gen.<...>` class names above and don't go through this dex + pipeline. + +### What to look at when this breaks + +* `ClassResolver.java` — the fallback decision between + `classStorageService` and `DexFactory`. +* `DexFactory.java` — runtime dex generation, cache, and the + `$`→`_` normalization. +* `ProxyGenerator.java` + `Dump.java` (under `runtime-binding-generator`) + — what the generated class actually looks like in bytecode. +* `android-static-binding-generator` — what SBG sees (and crucially + what it *doesn't* see) at build time. + ## Architecture Diagram The NativeScript Android Runtime architecture can be summarized in the following diagram. diff --git a/test-app/app/src/main/assets/app/mainpage.js b/test-app/app/src/main/assets/app/mainpage.js index 8184e7553..b6cf77fd3 100644 --- a/test-app/app/src/main/assets/app/mainpage.js +++ b/test-app/app/src/main/assets/app/mainpage.js @@ -77,3 +77,5 @@ require('./tests/testQueueMicrotask'); require("./tests/testConcurrentAccess"); require("./tests/testESModules.mjs"); +require("./tests/testHmrHotDataExt.mjs"); +require("./tests/testNodeBuiltinsAndOptionalModules.mjs"); diff --git a/test-app/app/src/main/assets/app/shared b/test-app/app/src/main/assets/app/shared index 3a262b979..0e030139e 160000 --- a/test-app/app/src/main/assets/app/shared +++ b/test-app/app/src/main/assets/app/shared @@ -1 +1 @@ -Subproject commit 3a262b979c6b84cdfe69cd495436a7088d016505 +Subproject commit 0e030139e7273975106cbedd69681f55d2c2fbf2 diff --git a/test-app/app/src/main/assets/app/tests/esm/hmr/hot-data-ext.js b/test-app/app/src/main/assets/app/tests/esm/hmr/hot-data-ext.js new file mode 100644 index 000000000..64e1d4816 --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/esm/hmr/hot-data-ext.js @@ -0,0 +1,79 @@ +// HMR hot.data test module (.js). +// +// INTENTIONAL twin of hot-data-ext.mjs. Two physical files with +// different extensions are required so the HMR canonical-key +// extension-collapse path is actually exercised by tests that import +// BOTH variants (see testHmrHotDataExt +// "should share hot.data across .mjs and .js variants"). Each file +// MUST own its own `import.meta.hot` reference — re-exporting from the +// sibling would defeat the test, because `dataMjs === dataJs` would +// then hold trivially via function identity instead of validating the +// runtime's canonical-key normalization. +// +// Keep the body in lock-step with `hot-data-ext.mjs`. + +export function getHot() { + return (typeof import.meta !== "undefined" && import.meta) ? import.meta.hot : undefined; +} + +export function getHotData() { + const hot = getHot(); + return hot ? hot.data : undefined; +} + +export function setHotValue(value) { + const hot = getHot(); + if (!hot || !hot.data) { + throw new Error("import.meta.hot.data is not available"); + } + hot.data.value = value; + return hot.data.value; +} + +export function getHotValue() { + const hot = getHot(); + return hot && hot.data ? hot.data.value : undefined; +} + +export function testHotApi() { + const hot = getHot(); + const result = { + ok: false, + hasHot: !!hot, + hasData: !!(hot && hot.data), + hasAccept: !!(hot && typeof hot.accept === "function"), + hasDispose: !!(hot && typeof hot.dispose === "function"), + hasDecline: !!(hot && typeof hot.decline === "function"), + hasInvalidate: !!(hot && typeof hot.invalidate === "function"), + hasPrune: !!(hot && typeof hot.prune === "function"), + }; + + try { + if (hot && typeof hot.accept === "function") { + hot.accept(function () {}); + } + if (hot && typeof hot.dispose === "function") { + hot.dispose(function () {}); + } + if (hot && typeof hot.decline === "function") { + hot.decline(); + } + if (hot && typeof hot.invalidate === "function") { + hot.invalidate(); + } + result.ok = + result.hasHot && + result.hasData && + result.hasAccept && + result.hasDispose && + result.hasDecline && + result.hasInvalidate && + result.hasPrune; + } catch (e) { + result.error = (e && e.message) ? e.message : String(e); + } + + return result; +} + +console.log("HMR hot.data ext module loaded (.js)"); diff --git a/test-app/app/src/main/assets/app/tests/esm/hmr/hot-data-ext.mjs b/test-app/app/src/main/assets/app/tests/esm/hmr/hot-data-ext.mjs new file mode 100644 index 000000000..7ff66c3b9 --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/esm/hmr/hot-data-ext.mjs @@ -0,0 +1,79 @@ +// HMR hot.data test module (.mjs). +// +// INTENTIONAL twin of hot-data-ext.js. Two physical files with +// different extensions are required so the HMR canonical-key +// extension-collapse path is actually exercised by tests that import +// BOTH variants (see testHmrHotDataExt +// "should share hot.data across .mjs and .js variants"). Each file +// MUST own its own `import.meta.hot` reference — re-exporting from the +// sibling would defeat the test, because `dataMjs === dataJs` would +// then hold trivially via function identity instead of validating the +// runtime's canonical-key normalization. +// +// Keep the body in lock-step with `hot-data-ext.js`. + +export function getHot() { + return (typeof import.meta !== "undefined" && import.meta) ? import.meta.hot : undefined; +} + +export function getHotData() { + const hot = getHot(); + return hot ? hot.data : undefined; +} + +export function setHotValue(value) { + const hot = getHot(); + if (!hot || !hot.data) { + throw new Error("import.meta.hot.data is not available"); + } + hot.data.value = value; + return hot.data.value; +} + +export function getHotValue() { + const hot = getHot(); + return hot && hot.data ? hot.data.value : undefined; +} + +export function testHotApi() { + const hot = getHot(); + const result = { + ok: false, + hasHot: !!hot, + hasData: !!(hot && hot.data), + hasAccept: !!(hot && typeof hot.accept === "function"), + hasDispose: !!(hot && typeof hot.dispose === "function"), + hasDecline: !!(hot && typeof hot.decline === "function"), + hasInvalidate: !!(hot && typeof hot.invalidate === "function"), + hasPrune: !!(hot && typeof hot.prune === "function"), + }; + + try { + if (hot && typeof hot.accept === "function") { + hot.accept(function () {}); + } + if (hot && typeof hot.dispose === "function") { + hot.dispose(function () {}); + } + if (hot && typeof hot.decline === "function") { + hot.decline(); + } + if (hot && typeof hot.invalidate === "function") { + hot.invalidate(); + } + result.ok = + result.hasHot && + result.hasData && + result.hasAccept && + result.hasDispose && + result.hasDecline && + result.hasInvalidate && + result.hasPrune; + } catch (e) { + result.error = (e && e.message) ? e.message : String(e); + } + + return result; +} + +console.log("HMR hot.data ext module loaded (.mjs)"); diff --git a/test-app/app/src/main/assets/app/tests/testHmrHotDataExt.mjs b/test-app/app/src/main/assets/app/tests/testHmrHotDataExt.mjs new file mode 100644 index 000000000..d9c8e7a15 --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testHmrHotDataExt.mjs @@ -0,0 +1,65 @@ +// HMR import.meta.hot.data sharing tests. +// +// These tests exercise the canonical-key extension-collapse path: +// importing the *same* logical module under `.mjs` and `.js` extensions +// MUST yield the same `import.meta.hot.data` object identity, so that +// state written from one variant is observable in the other. +// +// The two fixture files under `tests/esm/hmr/` MUST remain independent +// (no re-export of one from the other) — see the comment header in +// each fixture for why. +// +// HTTP-loader variants of these tests (live-tagged, boot-tagged, and +// /ns/core bridge URLs) live in HttpEsmLoaderTests on iOS. They depend +// on a dev-server harness that Android does not currently stand up, +// and are intentionally not ported here. The local twin-file path +// below still exercises the core canonical-key normalization. + +describe("HMR hot.data", function () { + it("exposes the import.meta.hot API surface", async function () { + const mod = await import("~/tests/esm/hmr/hot-data-ext.mjs"); + expect(mod).toBeTruthy(); + expect(typeof mod.testHotApi).toBe("function"); + + const result = mod.testHotApi(); + expect(result).toBeTruthy(); + if (!result.hasHot) { + pending("import.meta.hot not available (release build?)"); + return; + } + + expect(result.hasData).toBe(true); + expect(result.hasAccept).toBe(true); + expect(result.hasDispose).toBe(true); + expect(result.hasDecline).toBe(true); + expect(result.hasInvalidate).toBe(true); + expect(result.hasPrune).toBe(true); + expect(result.ok).toBe(true); + }); + + it("should share hot.data across .mjs and .js variants", async function () { + const [mjs, js] = await Promise.all([ + import("~/tests/esm/hmr/hot-data-ext.mjs"), + import("~/tests/esm/hmr/hot-data-ext.js"), + ]); + + const hotMjs = mjs && typeof mjs.getHot === "function" ? mjs.getHot() : null; + const hotJs = js && typeof js.getHot === "function" ? js.getHot() : null; + if (!hotMjs || !hotJs) { + pending("import.meta.hot not available (release build?)"); + return; + } + + const dataMjs = mjs.getHotData(); + const dataJs = js.getHotData(); + expect(dataMjs).toBeDefined(); + expect(dataJs).toBeDefined(); + + const token = "tok_" + Date.now() + "_" + Math.random(); + mjs.setHotValue(token); + expect(js.getHotValue()).toBe(token); + + // Canonical hot key strips common script extensions, so these must share identity. + expect(dataMjs).toBe(dataJs); + }); +}); diff --git a/test-app/app/src/main/assets/app/tests/testNodeBuiltinsAndOptionalModules.mjs b/test-app/app/src/main/assets/app/tests/testNodeBuiltinsAndOptionalModules.mjs new file mode 100644 index 000000000..c944fce6b --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testNodeBuiltinsAndOptionalModules.mjs @@ -0,0 +1,127 @@ +// Tests the resolver paths added in the HMR/ESM hardening port: +// - node: built-in polyfills (in-memory ES modules) +// - bare-specifier optional-module placeholders +// - ns-vendor:// vendor-registry resolution via configureRuntime importMap +// - blob: URL module re-use across imports +// +// The Android resolver's Node-builtin polyfill set is broader than iOS's; +// only the explicitly-tested specifiers are asserted here. + +describe("Node built-in and optional module resolution", function () { + it("provides an in-memory polyfill for node:url", async function () { + const mod = await import("node:url"); + const modAgain = await import("node:url"); + + expect(mod).toBeDefined(); + expect(modAgain).toBe(mod); + expect(typeof mod.fileURLToPath).toBe("function"); + expect(typeof mod.pathToFileURL).toBe("function"); + + const p = mod.fileURLToPath("file:///foo/bar.txt"); + expect(p === "/foo/bar.txt" || p === "foo/bar.txt").toBe(true); + + const u = mod.pathToFileURL("/foo/bar.txt"); + expect(u instanceof URL).toBe(true); + expect(u.protocol).toBe("file:"); + }); + + it("creates an in-memory placeholder for likely-optional modules", async function () { + // Use a name that IsLikelyOptionalModule will treat as optional + // (no slashes, no extension, no scope prefix). + const mod = await import("__ns_optional_test_module__"); + const modAgain = await import("__ns_optional_test_module__"); + + expect(mod).toBeDefined(); + expect(modAgain).toBe(mod); + expect(typeof mod.default).toBe("object"); + + let threw = false; + try { + // eslint-disable-next-line no-unused-expressions + mod.default.someProperty; + } catch (e) { + threw = true; + } + expect(threw).toBe(true); + }); + + it("resolves import-map vendor modules through the explicit vendor registry", async function () { + const configureRuntime = globalThis.__nsConfigureDevRuntime || globalThis.__nsConfigureRuntime; + if (typeof configureRuntime !== "function") { + pending("__nsConfigureDevRuntime not available (release build?)"); + return; + } + + const previousRegistry = globalThis.__nsVendorRegistry; + const vendorRegistry = new Map(); + globalThis.__nsVendorRegistry = vendorRegistry; + vendorRegistry.set("__ns_test_vendor__", { + default: { source: "vendor-default" }, + namedValue: 7, + makeValue() { + return "vendor-named"; + }, + }); + + try { + configureRuntime({ + importMap: { + imports: { + __ns_test_vendor__: "ns-vendor://__ns_test_vendor__", + }, + }, + }); + + const mod = await import("__ns_test_vendor__"); + const modAgain = await import("__ns_test_vendor__"); + + expect(mod).toBeDefined(); + expect(modAgain).toBe(mod); + expect(mod.default).toEqual({ source: "vendor-default" }); + expect(mod.namedValue).toBe(7); + expect(mod.makeValue()).toBe("vendor-named"); + } finally { + configureRuntime({ importMap: { imports: {} } }); + if (typeof previousRegistry === "undefined") { + delete globalThis.__nsVendorRegistry; + } else { + globalThis.__nsVendorRegistry = previousRegistry; + } + } + }); + + it("reuses blob URL modules across concurrent and repeated imports", async function () { + delete globalThis.__nsBlobEvalCount; + + const blobSource = [ + "globalThis.__nsBlobEvalCount = (globalThis.__nsBlobEvalCount || 0) + 1;", + "export const evalCount = globalThis.__nsBlobEvalCount;", + "export const kind = 'blob-module';", + "export default { evalCount, kind };", + ].join("\n"); + + const url = URL.createObjectURL(new Blob([blobSource], { type: "text/javascript" }), { + ext: ".mjs", + }); + + expect(typeof url).toBe("string"); + expect(url.indexOf("blob:nativescript/")).toBe(0); + + try { + const [first, second] = await Promise.all([import(url), import(url)]); + const third = await import(url); + + expect(first).toBeDefined(); + expect(second).toBe(first); + expect(third).toBe(first); + expect(first.evalCount).toBe(1); + expect(second.evalCount).toBe(1); + expect(third.evalCount).toBe(1); + expect(first.kind).toBe("blob-module"); + expect(globalThis.__nsBlobEvalCount).toBe(1); + } finally { + URL.revokeObjectURL(url); + delete globalThis.__nsBlobEvalCount; + } + }); +}); diff --git a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp index 5ce450fef..09d04865c 100644 --- a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp +++ b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp @@ -1345,6 +1345,22 @@ CallbackHandlers::WorkerObjectTerminateCallback(const v8::FunctionCallbackInfo &args) { + // `globalThis.__nsTerminateAllWorkers()` — main-isolate-only HMR helper. + // Tears down every worker parented by this isolate through the WorkerWrapper + // registry. TerminateChildren snapshots the registry under its lock, + // terminates and clears each worker, and lets each one cascade into its own + // nested workers, so a worker self-terminating in parallel can't invalidate + // the walk. Returns the number of direct (top-level) workers torn down so + // the HMR client can log it. + auto isolate = args.GetIsolate(); + HandleScope scope(isolate); + + int terminated = WorkerWrapper::TerminateChildren(isolate); + args.GetReturnValue().Set(terminated); +} + void CallbackHandlers::WorkerGlobalCloseCallback(const v8::FunctionCallbackInfo &args) { auto isolate = args.GetIsolate(); diff --git a/test-app/runtime/src/main/cpp/CallbackHandlers.h b/test-app/runtime/src/main/cpp/CallbackHandlers.h index eddcca93d..37f17c5ba 100644 --- a/test-app/runtime/src/main/cpp/CallbackHandlers.h +++ b/test-app/runtime/src/main/cpp/CallbackHandlers.h @@ -148,6 +148,16 @@ namespace tns { */ static void WorkerGlobalCloseCallback(const v8::FunctionCallbackInfo &args); + /* + * `globalThis.__nsTerminateAllWorkers()`, installed on the main-thread + * isolate only (debug builds). Terminates every worker parented by the + * main isolate via WorkerWrapper::TerminateChildren, which snapshots the + * registry, terminates and clears each worker, and lets each one cascade + * into its own nested workers. Returns the number of direct (top-level) + * workers torn down so the HMR client can log it. + */ + static void TerminateAllWorkersCallback(const v8::FunctionCallbackInfo &args); + /* * Is called when an unhandled exception is thrown inside the worker * Will execute 'onerror' if one is provided inside the Worker Scope diff --git a/test-app/runtime/src/main/cpp/DevFlags.cpp b/test-app/runtime/src/main/cpp/DevFlags.cpp index 224601b10..c826de43d 100644 --- a/test-app/runtime/src/main/cpp/DevFlags.cpp +++ b/test-app/runtime/src/main/cpp/DevFlags.cpp @@ -1,6 +1,7 @@ // DevFlags.cpp #include "DevFlags.h" #include "JEnv.h" +#include "NativeScriptAssert.h" #include #include #include @@ -8,21 +9,25 @@ namespace tns { -bool IsScriptLoadingLogEnabled() { - static std::atomic cached{-1}; // -1 unknown, 0 false, 1 true +// Cache the result of a parameterless `static boolean` method on +// `com.tns.Runtime`. `cached` and `initFlag` are caller-owned statics so +// every flag stays independently memoized; the helper does the JNI dance +// once and pins the result. Returns false (the safe default) if the +// class or method cannot be resolved. +static bool CachedBoolFlagFromJava(std::atomic& cached, + std::once_flag& initFlag, + const char* javaStaticBoolMethod) { int v = cached.load(std::memory_order_acquire); if (v != -1) { return v == 1; } - - static std::once_flag initFlag; - std::call_once(initFlag, []() { + std::call_once(initFlag, [&]() { bool enabled = false; try { JEnv env; jclass runtimeClass = env.FindClass("com/tns/Runtime"); if (runtimeClass != nullptr) { - jmethodID mid = env.GetStaticMethodID(runtimeClass, "getLogScriptLoadingEnabled", "()Z"); + jmethodID mid = env.GetStaticMethodID(runtimeClass, javaStaticBoolMethod, "()Z"); if (mid != nullptr) { jboolean res = env.CallStaticBooleanMethod(runtimeClass, mid); enabled = (res == JNI_TRUE); @@ -33,10 +38,67 @@ bool IsScriptLoadingLogEnabled() { } cached.store(enabled ? 1 : 0, std::memory_order_release); }); - return cached.load(std::memory_order_acquire) == 1; } +bool IsScriptLoadingLogEnabled() { + static std::atomic cached{-1}; + static std::once_flag initFlag; + return CachedBoolFlagFromJava(cached, initFlag, "getLogScriptLoadingEnabled"); +} + +// HTTP module loader flags +// +// Reads `httpModulePrefetch` from app config (default: DISABLED). +// +// Apps that want to opt in for testing can set in package.json: +// +// { +// "httpModulePrefetch": true +// } +// +// Returning false here short-circuits both the speculative-prefetch cache +// lookup (in HttpFetchText) and the prefetch wave (in KickstartHmrPrefetchSync / +// KickstartHmrPrefetchUrlsSync), restoring the pre-prefetcher behavior +// bit-for-bit. This is layered on top of the IsRemoteUrlAllowed network gate. +bool IsHttpModulePrefetchEnabled() { + static std::atomic cached{-1}; + static std::once_flag initFlag; + bool enabled = CachedBoolFlagFromJava(cached, initFlag, "getHttpModulePrefetchEnabled"); + + // Startup banner. Gated on the logScriptLoading flag so it stays silent + // by default — flip the flag in package.json when diagnosing why + // prefetch is or isn't engaging. + // [http-loader] prefetch=disabled ← expected default + // [http-loader] prefetch=enabled ← only if config opt-in + static std::once_flag bannerFlag; + std::call_once(bannerFlag, [enabled]() { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[http-loader] prefetch=%s shared-session=on hmr-kickstart=on", + enabled ? "enabled" : "disabled"); + } + }); + return enabled; +} + +// Default OFF because the volume is high (one line per fetch, hundreds per +// cold boot, hundreds per HMR refresh). Opt in via package.json: +// { "httpFetchUrlLog": true } +bool IsHttpFetchUrlLogEnabled() { + static std::atomic cached{-1}; + static std::once_flag initFlag; + bool enabled = CachedBoolFlagFromJava(cached, initFlag, "getHttpFetchUrlLogEnabled"); + + static std::once_flag bannerFlag; + std::call_once(bannerFlag, [enabled]() { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[http-loader] fetch-url-log=%s", + enabled ? "enabled" : "disabled"); + } + }); + return enabled; +} + // Security config static std::once_flag s_securityConfigInitFlag; @@ -110,6 +172,11 @@ bool IsRemoteModulesAllowed() { return s_allowRemoteModules || s_isDebuggable; } +bool IsDebuggable() { + InitializeSecurityConfig(); + return s_isDebuggable; +} + bool IsRemoteUrlAllowed(const std::string& url) { InitializeSecurityConfig(); diff --git a/test-app/runtime/src/main/cpp/DevFlags.h b/test-app/runtime/src/main/cpp/DevFlags.h index db571d49f..ec6bea410 100644 --- a/test-app/runtime/src/main/cpp/DevFlags.h +++ b/test-app/runtime/src/main/cpp/DevFlags.h @@ -9,6 +9,20 @@ namespace tns { // First call queries Java once; subsequent calls are atomic loads only. bool IsScriptLoadingLogEnabled(); +// HTTP module loader flags +// +// Returns true when speculative HTTP module prefetching (the dep-graph BFS +// kicked off after each successful HttpFetchText) should be enabled. Default +// OFF so cold-boot behaviour is unchanged for users who have not opted in. +// Controlled by package.json: "httpModulePrefetch": true|false +bool IsHttpModulePrefetchEnabled(); + +// Returns true when one log line should be emitted per HTTP fetch URL. +// Default OFF because the volume is high (one line per fetch, hundreds per +// cold boot, hundreds per HMR refresh). Opt in via package.json: +// "httpFetchUrlLog": true|false +bool IsHttpFetchUrlLogEnabled(); + // Security config // "security.allowRemoteModules" from nativescript.config @@ -18,6 +32,12 @@ bool IsRemoteModulesAllowed(); // If no allowlist is configured but allowRemoteModules is true, all URLs are allowed. bool IsRemoteUrlAllowed(const std::string& url); +// Mirrors com.tns.Runtime.isDebuggable() (config.isDebuggable), cached once via +// InitializeSecurityConfig(). Use this to gate dev/HMR-only native surfaces so +// they never execute in a plain release build. Returns false (fail-safe) until +// the security config has been initialized or if the JNI lookup fails. +bool IsDebuggable(); + // Init security configuration void InitializeSecurityConfig(); diff --git a/test-app/runtime/src/main/cpp/HMRSupport.cpp b/test-app/runtime/src/main/cpp/HMRSupport.cpp index 16cac04d8..61f6f5eea 100644 --- a/test-app/runtime/src/main/cpp/HMRSupport.cpp +++ b/test-app/runtime/src/main/cpp/HMRSupport.cpp @@ -1,38 +1,488 @@ // HMRSupport.cpp #include "HMRSupport.h" + #include "ArgConverter.h" -#include "JEnv.h" #include "DevFlags.h" +#include "JEnv.h" +#include "ModuleInternalCallbacks.h" #include "NativeScriptAssert.h" +#include "NativeScriptException.h" +#include "Runtime.h" + #include +#include #include -#include -#include +#include +#include #include +#include +#include #include +#include +#include +#include +#include namespace tns { +// ────────────────────────────────────────────────────────────────────────── +// Resolver-side helpers used by the dev-session machinery below +// (`ApplyDevRuntimeConfigObject`, `CollectSessionModuleUrls`). The actual +// definitions live in ModuleInternalCallbacks.cpp; this header-style +// forward block lets HMRSupport.cpp call them without pulling the +// resolver header in (avoids a circular include). +void SetImportMap(const std::string& json); +void SetVolatilePatterns(const std::vector& patterns); +std::vector GetLoadedModuleUrls(); + +// ────────────────────────────────────────────────────────────────────────── +// Local v8 string helper: thin convenience wrapper around the existing +// `ArgConverter::ConvertToV8String` so call sites can read more compactly. +static inline v8::Local ToV8String(v8::Isolate* isolate, const char* str) { + if (str == nullptr) { + return v8::String::Empty(isolate); + } + return v8::String::NewFromUtf8(isolate, str, v8::NewStringType::kNormal).ToLocalChecked(); +} + static inline bool StartsWith(const std::string& s, const char* prefix) { size_t n = strlen(prefix); return s.size() >= n && s.compare(0, n, prefix) == 0; } -// Per-module hot data and callbacks. Keyed by canonical module path (file path or URL). -static std::unordered_map> g_hotData; -static std::unordered_map>> g_hotAccept; -static std::unordered_map>> g_hotDispose; +static inline bool EndsWith(const std::string& s, const char* suffix) { + size_t n = strlen(suffix); + return s.size() >= n && s.compare(s.size() - n, n, suffix) == 0; +} + +// Per-module hot data and callbacks. Keyed by canonical module path. +// Heap-allocated (leaky singleton) to prevent V8 crash during __cxa_finalize_ranges. +// See g_moduleRegistry comment in ModuleInternalCallbacks.cpp for full rationale. +static auto* _g_hotData = new std::unordered_map>(); +static auto& g_hotData = *_g_hotData; +static auto* _g_hotAccept = new std::unordered_map>>(); +static auto& g_hotAccept = *_g_hotAccept; +static auto* _g_hotDispose = new std::unordered_map>>(); +static auto& g_hotDispose = *_g_hotDispose; +// Per-module prune callbacks (`import.meta.hot.prune(cb)`). Symmetric with +// `g_hotDispose` — separate registry because Vite spec semantics differ: +// `dispose` fires on every replacement (every HMR cycle), `prune` fires +// only when the module is removed from the dependency graph entirely. +static auto* _g_hotPrune = new std::unordered_map>>(); +static auto& g_hotPrune = *_g_hotPrune; + +// Custom event listeners +// Keyed by event name (global, not per-module) +static auto* _g_hotEventListeners = new std::unordered_map>>(); +static auto& g_hotEventListeners = *_g_hotEventListeners; + +// Set of canonical module keys that called `import.meta.hot.decline()`. +// The HMR client checks this set before applying an update — if any update +// touches a declined key, the update converts to a full reload. No V8 +// handles to clean up (just strings), so this lives in a plain set with +// its own mutex for thread safety. +static std::unordered_set g_hotDeclined; +static std::mutex g_hotDeclinedMutex; + +// Active deterministic dev-session state. +static DevSessionState g_activeDevSession; +static std::mutex g_activeDevSessionMutex; + +bool GetOptionalStringProperty(v8::Isolate* isolate, v8::Local context, + v8::Local object, const char* key, + std::string* out) { + if (out == nullptr) return false; + + v8::Local value; + if (!object->Get(context, ToV8String(isolate, key)).ToLocal(&value) || + value->IsUndefined() || value->IsNull()) { + return false; + } + + v8::Local stringValue; + if (!value->ToString(context).ToLocal(&stringValue)) { + return false; + } + + v8::String::Utf8Value utf8(isolate, stringValue); + *out = *utf8 ? *utf8 : ""; + return true; +} + +v8::Local CreateResolvedPromise(v8::Isolate* isolate, + v8::Local context) { + v8::Local resolver = + v8::Promise::Resolver::New(context).ToLocalChecked(); + resolver->Resolve(context, v8::Undefined(isolate)).FromMaybe(false); + return resolver->GetPromise(); +} + +v8::Local CreateRejectedPromise(v8::Local context, + v8::Local reason) { + v8::Local resolver = + v8::Promise::Resolver::New(context).ToLocalChecked(); + resolver->Reject(context, reason).FromMaybe(false); + return resolver->GetPromise(); +} + +void MirrorFunctionOnGlobalThis(v8::Isolate* isolate, v8::Local context, + const char* name) { + std::string src = + "if (typeof globalThis !== 'undefined' && typeof globalThis." + + std::string(name) + + " !== 'function') {" + " Object.defineProperty(globalThis, '" + std::string(name) + + "', { value: this." + std::string(name) + + ", writable: true, configurable: true, enumerable: false });" + "}"; + + v8::Local script; + if (v8::Script::Compile(context, ToV8String(isolate, src.c_str())) + .ToLocal(&script)) { + script->Run(context).FromMaybe(v8::Local()); + } +} + +static bool GetOptionalBooleanProperty(v8::Isolate* isolate, v8::Local context, + v8::Local object, const char* key, + bool* out) { + if (out == nullptr) return false; + + v8::Local value; + if (!object->Get(context, ToV8String(isolate, key)).ToLocal(&value) || + value->IsUndefined() || value->IsNull()) { + return false; + } + + *out = value->BooleanValue(isolate); + return true; +} + +static void SetBooleanGlobal(v8::Isolate* isolate, v8::Local context, + const char* key, bool value) { + context->Global() + ->Set(context, ToV8String(isolate, key), v8::Boolean::New(isolate, value)) + .FromMaybe(false); +} + +static void SetStringGlobal(v8::Isolate* isolate, v8::Local context, + const char* key, const std::string& value) { + context->Global() + ->Set(context, ToV8String(isolate, key), + ToV8String(isolate, value.c_str())) + .FromMaybe(false); +} + +static bool IsSupportedDevSessionPlatform(const std::string& platform) { + // Dev sessions only support the "android" platform identifier. + return platform == "android"; +} + +// Apply the v8::Object payload of `__nsConfigureDevRuntime`: re-validate the +// `importMap` shape and serialize it back to JSON for `SetImportMap`. Parsing +// runs entirely in V8 (via `ConfigureDevRuntimeCallback`), so this is a thin +// wrapper over that shared validation. +static bool ApplyDevRuntimeConfigObject(v8::Isolate* isolate, + v8::Local context, + v8::Local payload, + std::string* errorMessage) { + if (payload.IsEmpty()) { + if (errorMessage != nullptr) { + *errorMessage = "[__nsStartDevSession] runtime config payload must be an object"; + } + return false; + } + + v8::Local importMapValue; + if (!payload->Get(context, ToV8String(isolate, "importMap")).ToLocal(&importMapValue) || + !importMapValue->IsObject()) { + if (errorMessage != nullptr) { + *errorMessage = "[__nsStartDevSession] runtime config payload is missing importMap"; + } + return false; + } + + // Use JSON.stringify on the importMap object — keeps the on-disk format + // identical to what `__nsConfigureRuntime` already accepts. + v8::Local jsonObj; + v8::Local globalJson; + if (!context->Global()->Get(context, ToV8String(isolate, "JSON")).ToLocal(&globalJson) || + !globalJson->IsObject()) { + if (errorMessage != nullptr) { + *errorMessage = "[__nsStartDevSession] JSON global unavailable"; + } + return false; + } + jsonObj = globalJson.As(); + + v8::Local stringifyFnVal; + if (!jsonObj->Get(context, ToV8String(isolate, "stringify")).ToLocal(&stringifyFnVal) || + !stringifyFnVal->IsFunction()) { + if (errorMessage != nullptr) { + *errorMessage = "[__nsStartDevSession] JSON.stringify unavailable"; + } + return false; + } + + v8::Local stringifyFn = stringifyFnVal.As(); + v8::Local args[] = {importMapValue}; + v8::MaybeLocal maybeJson = stringifyFn->Call(context, jsonObj, 1, args); + v8::Local jsonVal; + if (!maybeJson.ToLocal(&jsonVal) || !jsonVal->IsString()) { + if (errorMessage != nullptr) { + *errorMessage = "[__nsStartDevSession] failed to serialize importMap"; + } + return false; + } + + v8::String::Utf8Value jsonUtf8(isolate, jsonVal); + std::string importMapJson = *jsonUtf8 ? *jsonUtf8 : ""; + if (importMapJson.empty()) { + if (errorMessage != nullptr) { + *errorMessage = "[__nsStartDevSession] runtime config importMap was empty"; + } + return false; + } + + SetImportMap(importMapJson); + + std::vector patterns; + v8::Local volatilePatternsValue; + if (payload->Get(context, ToV8String(isolate, "volatilePatterns")).ToLocal(&volatilePatternsValue) && + volatilePatternsValue->IsArray()) { + v8::Local arr = volatilePatternsValue.As(); + uint32_t length = arr->Length(); + for (uint32_t i = 0; i < length; ++i) { + v8::Local entry; + if (!arr->Get(context, i).ToLocal(&entry)) continue; + if (!entry->IsString()) continue; + v8::String::Utf8Value utf8(isolate, entry); + if (*utf8 && (*utf8)[0] != '\0') { + patterns.emplace_back(*utf8); + } + } + } + + if (!patterns.empty()) { + SetVolatilePatterns(patterns); + } + + return true; +} v8::Local GetOrCreateHotData(v8::Isolate* isolate, const std::string& key) { auto it = g_hotData.find(key); - if (it != g_hotData.end() && !it->second.IsEmpty()) { - return it->second.Get(isolate); + if (it != g_hotData.end()) { + if (!it->second.IsEmpty()) { + return it->second.Get(isolate); + } } v8::Local obj = v8::Object::New(isolate); g_hotData[key].Reset(isolate, obj); return obj; } +bool ReadDevSessionConfig(v8::Isolate* isolate, v8::Local context, + v8::Local config, DevSessionState* out, + std::string* errorMessage) { + if (out == nullptr) { + if (errorMessage != nullptr) { + *errorMessage = "[__nsStartDevSession] output session state is required"; + } + return false; + } + + DevSessionState next; + next.active = true; + GetOptionalStringProperty(isolate, context, config, "sessionId", &next.sessionId); + GetOptionalStringProperty(isolate, context, config, "origin", &next.origin); + GetOptionalStringProperty(isolate, context, config, "entryUrl", &next.entryUrl); + GetOptionalStringProperty(isolate, context, config, "clientUrl", &next.clientUrl); + GetOptionalStringProperty(isolate, context, config, "wsUrl", &next.wsUrl); + GetOptionalStringProperty(isolate, context, config, "platform", &next.platform); + GetOptionalStringProperty(isolate, context, config, "runtimeConfigUrl", &next.runtimeConfigUrl); + + v8::Local featuresValue; + if (config->Get(context, ToV8String(isolate, "features")) + .ToLocal(&featuresValue) && + featuresValue->IsObject()) { + v8::Local features = featuresValue.As(); + GetOptionalBooleanProperty(isolate, context, features, "fullReload", + &next.fullReload); + GetOptionalBooleanProperty(isolate, context, features, "cssHmr", + &next.cssHmr); + } + + if (next.sessionId.empty() || next.origin.empty() || next.entryUrl.empty() || + next.clientUrl.empty() || next.wsUrl.empty() || next.platform.empty()) { + if (errorMessage != nullptr) { + *errorMessage = + "[__nsStartDevSession] sessionId, origin, clientUrl, wsUrl, entryUrl, and platform are required"; + } + return false; + } + + if (!IsSupportedDevSessionPlatform(next.platform)) { + if (errorMessage != nullptr) { + *errorMessage = + "[__nsStartDevSession] platform must be android"; + } + return false; + } + + *out = next; + return true; +} + +void ResetActiveDevSession() { + std::lock_guard lock(g_activeDevSessionMutex); + if (IsScriptLoadingLogEnabled() && g_activeDevSession.active) { + DEBUG_WRITE("[dev-session] reset active session=%s started=%s", + g_activeDevSession.sessionId.c_str(), + g_activeDevSession.started ? "true" : "false"); + } + g_activeDevSession = DevSessionState(); +} + +DevSessionState GetActiveDevSessionSnapshot() { + std::lock_guard lock(g_activeDevSessionMutex); + return g_activeDevSession; +} + +void StoreActiveDevSession(const DevSessionState& session) { + std::lock_guard lock(g_activeDevSessionMutex); + g_activeDevSession = session; + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dev-session] stored session=%s started=%s origin=%s client=%s entry=%s", + session.sessionId.c_str(), session.started ? "true" : "false", + session.origin.c_str(), session.clientUrl.c_str(), + session.entryUrl.c_str()); + } +} + +bool HasDevSessionChanged(const DevSessionState& previous, + const DevSessionState& next) { + return !previous.active || previous.sessionId != next.sessionId || + previous.origin != next.origin || previous.entryUrl != next.entryUrl || + previous.clientUrl != next.clientUrl || previous.wsUrl != next.wsUrl || + previous.runtimeConfigUrl != next.runtimeConfigUrl; +} + +std::vector CollectSessionModuleUrls(const DevSessionState& session) { + std::vector invalidate; + if (!session.active || session.origin.empty()) { + return invalidate; + } + + for (const auto& url : tns::GetLoadedModuleUrls()) { + if (!StartsWith(url, session.origin.c_str())) continue; + if (!session.clientUrl.empty() && url == session.clientUrl) continue; + invalidate.push_back(url); + } + + return invalidate; +} + +bool ApplyDevRuntimeConfigFromUrl(const std::string& url, + std::string* errorMessage) { + if (url.empty()) { + return true; + } + + std::string body; + std::string contentType; + int status = 0; + if (!HttpFetchText(url, body, contentType, status) || body.empty()) { + if (errorMessage != nullptr) { + *errorMessage = std::string("[__nsStartDevSession] failed to fetch runtimeConfigUrl: ") + url; + } + return false; + } + + // Parse the JSON response in V8: dev-session bootstrap runs on the JS thread, + // so a live isolate is available. + v8::Isolate* isolate = v8::Isolate::TryGetCurrent(); + if (isolate == nullptr) { + if (errorMessage != nullptr) { + *errorMessage = "[__nsStartDevSession] no current V8 isolate to parse runtime config"; + } + return false; + } + + v8::HandleScope scope(isolate); + v8::Local context = isolate->GetCurrentContext(); + if (context.IsEmpty()) { + if (errorMessage != nullptr) { + *errorMessage = "[__nsStartDevSession] no current V8 context to parse runtime config"; + } + return false; + } + + v8::TryCatch tc(isolate); + v8::Local bodyStr = v8::String::NewFromUtf8( + isolate, body.c_str(), v8::NewStringType::kNormal, + static_cast(body.size())).ToLocalChecked(); + v8::MaybeLocal maybeParsed = v8::JSON::Parse(context, bodyStr); + v8::Local parsed; + if (!maybeParsed.ToLocal(&parsed) || !parsed->IsObject()) { + if (errorMessage != nullptr) { + std::string detail = "unknown runtime config parse error"; + if (tc.HasCaught()) { + v8::String::Utf8Value msg(isolate, tc.Exception()); + if (*msg) detail = *msg; + } + *errorMessage = std::string("[__nsStartDevSession] failed to parse runtime config: ") + detail; + } + return false; + } + + if (!ApplyDevRuntimeConfigObject(isolate, context, parsed.As(), errorMessage)) { + return false; + } + + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dev-session] runtime config applied url=%s", url.c_str()); + } + + return true; +} + +// Native-side mirror of `__NS_HMR_BOOT_COMPLETE__`. Read by the +// runloop pump in `MaybePumpJSThreadDuringBoot` so its gate is a +// single relaxed atomic load on the HMR-time hot path. +static std::atomic g_devSessionBootComplete{false}; + +static inline bool IsDevSessionBootComplete() { + return g_devSessionBootComplete.load(std::memory_order_relaxed); +} + +void ApplyDevSessionGlobals(v8::Isolate* isolate, + v8::Local context, + const DevSessionState& session) { + SetStringGlobal(isolate, context, "__NS_HTTP_ORIGIN__", session.origin); + SetStringGlobal(isolate, context, "__NS_HMR_WS_URL__", session.wsUrl); + SetBooleanGlobal(isolate, context, "__NS_HMR_BOOT_COMPLETE__", false); + SetBooleanGlobal(isolate, context, "__NS_HMR_CLIENT_ACTIVE__", false); + SetBooleanGlobal(isolate, context, "__NS_HMR_BROWSER_RUNTIME_CLIENT_ACTIVE__", false); + g_devSessionBootComplete.store(false, std::memory_order_relaxed); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dev-session] globals applied session=%s origin=%s ws=%s bootComplete=false", + session.sessionId.c_str(), session.origin.c_str(), + session.wsUrl.c_str()); + } +} + +void SetDevSessionBootComplete(v8::Isolate* isolate, + v8::Local context, + bool value) { + SetBooleanGlobal(isolate, context, "__NS_HMR_BOOT_COMPLETE__", value); + g_devSessionBootComplete.store(value, std::memory_order_relaxed); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dev-session] __NS_HMR_BOOT_COMPLETE__=%s", + value ? "true" : "false"); + } +} + void RegisterHotAccept(v8::Isolate* isolate, const std::string& key, v8::Local cb) { if (cb.IsEmpty()) return; g_hotAccept[key].emplace_back(v8::Global(isolate, cb)); @@ -43,6 +493,11 @@ void RegisterHotDispose(v8::Isolate* isolate, const std::string& key, v8::Local< g_hotDispose[key].emplace_back(v8::Global(isolate, cb)); } +void RegisterHotPrune(v8::Isolate* isolate, const std::string& key, v8::Local cb) { + if (cb.IsEmpty()) return; + g_hotPrune[key].emplace_back(v8::Global(isolate, cb)); +} + std::vector> GetHotAcceptCallbacks(v8::Isolate* isolate, const std::string& key) { std::vector> out; auto it = g_hotAccept.find(key); @@ -65,6 +520,425 @@ std::vector> GetHotDisposeCallbacks(v8::Isolate* isolate return out; } +std::vector> GetHotPruneCallbacks(v8::Isolate* isolate, const std::string& key) { + std::vector> out; + auto it = g_hotPrune.find(key); + if (it != g_hotPrune.end()) { + for (auto& gfn : it->second) { + if (!gfn.IsEmpty()) out.push_back(gfn.Get(isolate)); + } + } + return out; +} + +void RegisterHotEventListener(v8::Isolate* isolate, const std::string& event, v8::Local cb) { + if (cb.IsEmpty()) return; + g_hotEventListeners[event].emplace_back(v8::Global(isolate, cb)); +} + +void RemoveHotEventListener(v8::Isolate* isolate, const std::string& event, v8::Local cb) { + if (cb.IsEmpty()) return; + auto it = g_hotEventListeners.find(event); + if (it == g_hotEventListeners.end()) return; + auto& listeners = it->second; + // V8 strict equality — same Function reference. A user that registered + // the same closure twice gets BOTH copies removed; matches + // `EventTarget.removeEventListener` semantics for repeated registrations. + for (auto i = listeners.begin(); i != listeners.end();) { + if (!i->IsEmpty() && i->Get(isolate) == cb) { + i->Reset(); + i = listeners.erase(i); + } else { + ++i; + } + } + if (listeners.empty()) { + g_hotEventListeners.erase(it); + } +} + +void MarkHotDeclined(const std::string& key) { + if (key.empty()) return; + std::lock_guard lock(g_hotDeclinedMutex); + g_hotDeclined.insert(key); +} + +bool IsHotDeclined(const std::string& key) { + if (key.empty()) return false; + std::lock_guard lock(g_hotDeclinedMutex); + return g_hotDeclined.find(key) != g_hotDeclined.end(); +} + +bool IsAnyModuleDeclined(const std::vector& keys) { + std::lock_guard lock(g_hotDeclinedMutex); + if (g_hotDeclined.empty()) return false; + if (keys.empty()) { + // "Is anything declined?" — yes if the set is non-empty (already + // checked above). + return true; + } + for (const auto& k : keys) { + if (g_hotDeclined.find(k) != g_hotDeclined.end()) return true; + } + return false; +} + +std::vector> GetHotEventListeners(v8::Isolate* isolate, const std::string& event) { + std::vector> out; + auto it = g_hotEventListeners.find(event); + if (it != g_hotEventListeners.end()) { + for (auto& gfn : it->second) { + if (!gfn.IsEmpty()) out.push_back(gfn.Get(isolate)); + } + } + return out; +} + +void DispatchHotEvent(v8::Isolate* isolate, v8::Local context, const std::string& event, v8::Local data) { + auto callbacks = GetHotEventListeners(isolate, event); + const bool verbose = tns::IsScriptLoadingLogEnabled(); + + // Single dispatch loop. Always observe `tryCatch.HasCaught()` and + // `result.ToLocal(...)` for every listener (not just in verbose mode) so the + // dispatcher's behavior never depends on whether logging is enabled. + // + // All `DEBUG_WRITE()` calls are gated behind `verbose`, so default dev + // sessions stay quiet; the per-listener counters are cheap and feed a + // verbose-only summary of whether any listener matched — the most useful + // signal during HMR triage (enable with `logScriptLoading: true`). + int matched = 0; // returned undefined OR a truthy non-bool (Promise/object) + int falsey = 0; // returned literal `false` + int threw = 0; // listener threw synchronously + int idx = 0; + for (auto& cb : callbacks) { + v8::TryCatch tryCatch(isolate); + v8::Local args[] = { data }; + v8::MaybeLocal result = cb->Call(context, v8::Undefined(isolate), 1, args); + if (tryCatch.HasCaught()) { + threw++; + if (verbose) { + v8::Local ex = tryCatch.Exception(); + v8::String::Utf8Value m(isolate, ex); + DEBUG_WRITE("[import.meta.hot] Listener #%d for '%s' threw: %s", idx, event.c_str(), *m ? *m : "(unknown)"); + } + } else { + v8::Local ret; + if (result.ToLocal(&ret)) { + if (ret->IsBoolean() && !ret->BooleanValue(isolate)) { + falsey++; + } else { + matched++; + if (verbose && !ret->IsUndefined()) { + v8::String::Utf8Value rstr(isolate, ret); + std::string s = *rstr ? *rstr : "(unknown)"; + DEBUG_WRITE("[import.meta.hot] Listener #%d for '%s' returned: %s", idx, event.c_str(), s.c_str()); + } + } + } + } + idx++; + } + if (verbose) { + DEBUG_WRITE("[import.meta.hot] dispatch summary event='%s' total=%d matched=%d falsey=%d threw=%d", + event.c_str(), (int)callbacks.size(), matched, falsey, threw); + } +} + +void InitializeHotEventDispatcher(v8::Isolate* isolate, v8::Local context) { + using v8::FunctionCallbackInfo; + using v8::Local; + using v8::Value; + + // Create a global function __NS_DISPATCH_HOT_EVENT__(event, data) + // that the HMR client can call to dispatch events to registered listeners. + // Returns the number of listeners that were invoked so callers can detect + // "no-listener" scenarios (which would otherwise look identical to a + // successful dispatch from the JS side). + auto dispatchCb = [](const FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::Local ctx = iso->GetCurrentContext(); + + if (info.Length() < 1 || !info[0]->IsString()) { + info.GetReturnValue().Set(v8::Integer::New(iso, -1)); + return; + } + + v8::String::Utf8Value eventName(iso, info[0]); + std::string event = *eventName ? *eventName : ""; + if (event.empty()) { + info.GetReturnValue().Set(v8::Integer::New(iso, -1)); + return; + } + + v8::Local data = info.Length() > 1 ? info[1] : v8::Undefined(iso).As(); + + auto callbacks = GetHotEventListeners(iso, event); + + if (tns::IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[import.meta.hot] Dispatching event '%s' to %d listener(s)", event.c_str(), (int)callbacks.size()); + } + + DispatchHotEvent(iso, ctx, event, data); + info.GetReturnValue().Set(v8::Integer::New(iso, (int)callbacks.size())); + }; + + // __nsListHotEventListeners() — returns an object mapping every registered + // event name to its current listener count. Diagnostic helper for HMR + // dispatch issues so JS code can verify whether a given event has any + // listeners attached at the time of dispatch (the typical failure mode is + // a custom event being dispatched before the user's compiled component + // module has executed its `import.meta.hot.on(...)` registration). + auto listCb = [](const FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::Local ctx = iso->GetCurrentContext(); + v8::Local result = v8::Object::New(iso); + for (const auto& kv : g_hotEventListeners) { + v8::Local name = ToV8String(iso, kv.first.c_str()); + v8::Local count = v8::Integer::New(iso, (int)kv.second.size()); + (void)result->CreateDataProperty(ctx, name, count); + } + info.GetReturnValue().Set(result); + }; + + v8::Local global = context->Global(); + v8::Local dispatchFn = v8::Function::New(context, dispatchCb).ToLocalChecked(); + global->CreateDataProperty(context, ToV8String(isolate, "__NS_DISPATCH_HOT_EVENT__"), dispatchFn).Check(); + v8::Local listFn = v8::Function::New(context, listCb).ToLocalChecked(); + global->CreateDataProperty(context, ToV8String(isolate, "__nsListHotEventListeners"), listFn).Check(); +} + +namespace { + +// Shared drainer for the dispose/prune twin runners. Both have identical +// snapshot-and-swap semantics (re-entrancy safety, mid-drain +// re-registration, per-callback try/catch with a script-loading log); the +// only things that differ between them are the registry map they touch +// and the log tag. Extracting the common body keeps any future fix to +// the drain protocol from drifting between the two paths. +// +// `registry` is taken by reference so the caller's file-static map is +// mutated in place. +int DrainHotCallbacks( + v8::Isolate* isolate, v8::Local context, + const std::vector& keys, + std::unordered_map>>& registry, + const char* logTag) { + using v8::Function; + using v8::Global; + using v8::HandleScope; + using v8::Local; + using v8::Object; + using v8::TryCatch; + using v8::Value; + + // Snapshot the keys we'll drain so callers passing an empty list get + // every registered module. We snapshot first (rather than iterating the + // map directly) so the registry can be safely mutated mid-drain — both + // when we erase entries below, and if a callback itself registers a + // new dispose/prune for the same module (legal per Vite spec; lets + // users implement hot-data persistence and re-arm side effects). + std::vector targetKeys; + if (keys.empty()) { + targetKeys.reserve(registry.size()); + for (const auto& kv : registry) { + targetKeys.push_back(kv.first); + } + } else { + targetKeys = keys; + } + + if (targetKeys.empty()) return 0; + + HandleScope handleScope(isolate); + int executed = 0; + + for (const auto& key : targetKeys) { + auto it = registry.find(key); + if (it == registry.end() || it->second.empty()) continue; + + // Move callbacks out of the registry BEFORE invoking. This prevents: + // * Re-entrant drain calls from re-firing the same callbacks. + // * Callbacks that re-register on the same module from racing with + // our iteration — their newly-registered cb lands in the + // now-empty bucket and survives until the next drain (the + // correct Vite-spec behaviour for a module that re-installs + // side-effects after running cleanup). + std::vector> callbacks; + callbacks.swap(it->second); + registry.erase(it); + + // The user-visible callback signature is `(data) => void`. Pass the + // module's `hot.data` so users can stash state across the reload — + // matches Vite's contract documented at: + // https://vite.dev/guide/api-hmr#hot-dispose-cb + // https://vite.dev/guide/api-hmr#hot-prune-cb + Local data = GetOrCreateHotData(isolate, key); + Local args[] = { data }; + + for (auto& gfn : callbacks) { + if (gfn.IsEmpty()) continue; + Local cb = gfn.Get(isolate); + if (cb.IsEmpty()) continue; + + TryCatch tryCatch(isolate); + v8::MaybeLocal result = cb->Call(context, v8::Undefined(isolate), 1, args); + (void)result; + if (tryCatch.HasCaught()) { + // One bad callback must NEVER take down the HMR cycle for + // everyone else. Log under the existing script-loading flag so + // the user has a way to enable diagnostic visibility without + // recompiling, and continue. + if (tns::IsScriptLoadingLogEnabled()) { + Local ex = tryCatch.Exception(); + v8::String::Utf8Value msg(isolate, ex); + DEBUG_WRITE("%s callback threw for key=%s: %s", + logTag, key.c_str(), *msg ? *msg : "(unknown)"); + } + // Don't ReThrow — swallow per-callback failures so subsequent + // drains (and the reboot itself) still run. + continue; + } + ++executed; + } + } + + return executed; +} + +} // namespace + +int RunHotDisposeCallbacks(v8::Isolate* isolate, v8::Local context, + const std::vector& keys) { + return DrainHotCallbacks(isolate, context, keys, g_hotDispose, + "[import.meta.hot.dispose]"); +} + +void InitializeHotDisposeRunner(v8::Isolate* isolate, v8::Local context) { + using v8::FunctionCallbackInfo; + using v8::Local; + using v8::Value; + + // Global JS-callable: `__nsRunHmrDispose(keys?: string[]) => number`. + // Drains `import.meta.hot.dispose` callbacks and returns how many ran. With + // no argument (or a non-array) it drains every registered module; an array of + // keys drains only those modules. + auto runDisposeCb = [](const FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::Local ctx = iso->GetCurrentContext(); + + std::vector keys; + if (info.Length() >= 1 && info[0]->IsArray()) { + v8::Local arr = info[0].As(); + uint32_t length = arr->Length(); + keys.reserve(length); + for (uint32_t i = 0; i < length; ++i) { + v8::Local entry; + if (!arr->Get(ctx, i).ToLocal(&entry)) continue; + if (!entry->IsString()) continue; + v8::String::Utf8Value s(iso, entry); + if (*s) keys.emplace_back(*s); + } + } + // info[0] is null/undefined/missing/non-array → empty `keys` → drain all. + + int executed = RunHotDisposeCallbacks(iso, ctx, keys); + info.GetReturnValue().Set(static_cast(executed)); + }; + + v8::Local global = context->Global(); + v8::Local fn = v8::Function::New(context, runDisposeCb).ToLocalChecked(); + global->CreateDataProperty(context, + ToV8String(isolate, "__nsRunHmrDispose"), + fn).Check(); +} + +int RunHotPruneCallbacks(v8::Isolate* isolate, v8::Local context, + const std::vector& keys) { + return DrainHotCallbacks(isolate, context, keys, g_hotPrune, + "[import.meta.hot.prune]"); +} + +void InitializeHotPruneRunner(v8::Isolate* isolate, v8::Local context) { + using v8::FunctionCallbackInfo; + using v8::Local; + using v8::Value; + + // Global JS-callable: `__nsRunHmrPrune(keys?: string[]) => number`. + // Symmetric with `__nsRunHmrDispose`, draining `import.meta.hot.prune` + // callbacks. No argument drains all registered modules; an array of keys + // drains only those. + auto runPruneCb = [](const FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::Local ctx = iso->GetCurrentContext(); + + std::vector keys; + if (info.Length() >= 1 && info[0]->IsArray()) { + v8::Local arr = info[0].As(); + uint32_t length = arr->Length(); + keys.reserve(length); + for (uint32_t i = 0; i < length; ++i) { + v8::Local entry; + if (!arr->Get(ctx, i).ToLocal(&entry)) continue; + if (!entry->IsString()) continue; + v8::String::Utf8Value s(iso, entry); + if (*s) keys.emplace_back(*s); + } + } + + int executed = RunHotPruneCallbacks(iso, ctx, keys); + info.GetReturnValue().Set(static_cast(executed)); + }; + + v8::Local global = context->Global(); + v8::Local fn = v8::Function::New(context, runPruneCb).ToLocalChecked(); + global->CreateDataProperty(context, + ToV8String(isolate, "__nsRunHmrPrune"), + fn).Check(); +} + +void InitializeHotDeclinedHelper(v8::Isolate* isolate, v8::Local context) { + using v8::FunctionCallbackInfo; + using v8::Local; + using v8::Value; + + // Global JS-callable: `__nsHasDeclinedModule(keys?: string[]) => boolean`. + // The Angular HMR client passes the eviction-set (`msg.evictPaths`) here + // before applying an update; on `true` it falls back to a full reload via + // `__nsReloadDevApp` instead of the per-cycle reboot. + // + // No-arg form ("is anything declined at all?") returns `true` if any + // module ever called `import.meta.hot.decline()`. Useful as a coarse + // pre-check: if the answer is `false` the client can skip the more + // expensive per-key check below. + auto hasDeclinedCb = [](const FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::Local ctx = iso->GetCurrentContext(); + + std::vector keys; + if (info.Length() >= 1 && info[0]->IsArray()) { + v8::Local arr = info[0].As(); + uint32_t length = arr->Length(); + keys.reserve(length); + for (uint32_t i = 0; i < length; ++i) { + v8::Local entry; + if (!arr->Get(ctx, i).ToLocal(&entry)) continue; + if (!entry->IsString()) continue; + v8::String::Utf8Value s(iso, entry); + if (*s) keys.emplace_back(*s); + } + } + + bool declined = IsAnyModuleDeclined(keys); + info.GetReturnValue().Set(declined); + }; + + v8::Local global = context->Global(); + v8::Local fn = v8::Function::New(context, hasDeclinedCb).ToLocalChecked(); + global->CreateDataProperty(context, + ToV8String(isolate, "__nsHasDeclinedModule"), + fn).Check(); +} + void InitializeImportMetaHot(v8::Isolate* isolate, v8::Local context, v8::Local importMeta, @@ -76,12 +950,124 @@ void InitializeImportMetaHot(v8::Isolate* isolate, using v8::String; using v8::Value; + // Ensure context scope for property creation v8::HandleScope scope(isolate); - auto makeKeyData = [&](const std::string& key) -> Local { - return ArgConverter::ConvertToV8String(isolate, key); + // Canonicalize key to ensure per-module hot.data persists across HMR URLs. + // Important: this must NOT affect the HTTP loader cache key; otherwise HMR fetches + // can collapse onto an already-evaluated module and no update occurs. + auto canonicalHotKey = [&](const std::string& in) -> std::string { + // Unwrap file://http(s)://... + std::string s = in; + if (StartsWith(s, "file://http://") || StartsWith(s, "file://https://")) { + s = s.substr(strlen("file://")); + } + + const bool isHttpUrl = StartsWith(s, "http://") || StartsWith(s, "https://"); + if (isHttpUrl) { + // Preserve meaningful dev-endpoint query identity (for example /ns/core?p=...) + // while still dropping cache-busters and canonicalizing versioned bridge URLs. + s = CanonicalizeHttpUrlKey(s); + } + + // Drop fragment + size_t hashPos = s.find('#'); + if (hashPos != std::string::npos) s = s.substr(0, hashPos); + + std::string noQuery = s; + std::string suffix; + if (!isHttpUrl) { + size_t qPos = s.find('?'); + noQuery = (qPos == std::string::npos) ? s : s.substr(0, qPos); + } + + // If it's an http(s) URL, normalize only the path portion below. + size_t schemePos = noQuery.find("://"); + size_t pathStart = (schemePos == std::string::npos) ? 0 : noQuery.find('/', schemePos + 3); + if (pathStart == std::string::npos) { + // No path; return without query + return noQuery; + } + + std::string origin = noQuery.substr(0, pathStart); + std::string pathAndSuffix = noQuery.substr(pathStart); + if (isHttpUrl) { + size_t qPos = pathAndSuffix.find('?'); + if (qPos != std::string::npos) { + suffix = pathAndSuffix.substr(qPos); + pathAndSuffix = pathAndSuffix.substr(0, qPos); + } + } + std::string path = pathAndSuffix; + + // Normalize NS HMR virtual module paths: + // /ns/m/__ns_hmr__// -> /ns/m/ + auto normalizeHmrVirtualPath = [&](const char* prefix) { + size_t prefixLen = strlen(prefix); + if (path.compare(0, prefixLen, prefix) != 0) { + return false; + } + + size_t nextSlash = path.find('/', prefixLen); + if (nextSlash == std::string::npos) { + return false; + } + + path = std::string("/ns/m/") + path.substr(nextSlash + 1); + return true; + }; + + // Keep import.meta.hot.data stable across both live-tagged and boot-tagged HMR URLs. + if (!normalizeHmrVirtualPath("/ns/m/__ns_boot__/b1/__ns_hmr__/")) { + normalizeHmrVirtualPath("/ns/m/__ns_hmr__/"); + } + + auto normalizeBridge = [&](const char* needle) { + size_t nlen = strlen(needle); + if (path.compare(0, nlen, needle) != 0) return; + if (path.size() == nlen) return; + if (path.size() <= nlen + 1 || path[nlen] != '/') return; + + size_t i = nlen + 1; + size_t j = i; + while (j < path.size() && std::isdigit(static_cast(path[j]))) { + j++; + } + if (j == i) return; + if (j != path.size()) return; + + path = std::string(needle); + }; + + normalizeBridge("/ns/rt"); + normalizeBridge("/ns/core"); + + // Normalize common script extensions so `/foo` and `/foo.ts` share hot.data. + const char* exts[] = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"}; + for (auto ext : exts) { + if (EndsWith(path, ext)) { + path = path.substr(0, path.size() - strlen(ext)); + break; + } + } + + // Also drop `.vue`? No — SFC endpoints should stay distinct. + return origin + path + suffix; + }; + + const std::string key = canonicalHotKey(modulePath); + if (tns::IsScriptLoadingLogEnabled()) { + bool isReload = (g_hotData.find(key) != g_hotData.end()); + DEBUG_WRITE("[hmr][import.meta.hot] module=%s key=%s isReload=%d", modulePath.c_str(), key.c_str(), isReload); + } + + // Helper to capture key in function data + auto makeKeyData = [&](const std::string& k) -> Local { + return ToV8String(isolate, k.c_str()); }; + // accept([deps], cb?) — register cb if provided. The deps array is accepted + // for Vite API compatibility but does not drive selective acceptance. auto acceptCb = [](const FunctionCallbackInfo& info) { v8::Isolate* iso = info.GetIsolate(); Local data = info.Data(); @@ -99,9 +1085,11 @@ void InitializeImportMetaHot(v8::Isolate* isolate, if (!cb.IsEmpty()) { RegisterHotAccept(iso, key, cb); } + // Return undefined info.GetReturnValue().Set(v8::Undefined(iso)); }; + // dispose(cb) — register disposer auto disposeCb = [](const FunctionCallbackInfo& info) { v8::Isolate* iso = info.GetIsolate(); Local data = info.Data(); @@ -113,43 +1101,509 @@ void InitializeImportMetaHot(v8::Isolate* isolate, info.GetReturnValue().Set(v8::Undefined(iso)); }; + // prune(cb) — register a callback that fires when this module is removed + // from the dep graph (NOT on every replacement — that's `dispose`). Today + // the NS HMR pipeline does wholesale reboots so prune callbacks rarely + // fire, but the registry is plumbed end-to-end so a future per-module + // HMR client can drain `g_hotPrune` via `__nsRunHmrPrune`. + auto pruneCb = [](const FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + Local data = info.Data(); + std::string key; + if (!data.IsEmpty()) { v8::String::Utf8Value s(iso, data); key = *s ? *s : ""; } + if (info.Length() >= 1 && info[0]->IsFunction()) { + RegisterHotPrune(iso, key, info[0].As()); + } + info.GetReturnValue().Set(v8::Undefined(iso)); + }; + + // decline() — mark this module as not hot-updateable (Vite spec). Adds the + // canonical key to `g_hotDeclined`; the HMR client checks this set via + // `__nsHasDeclinedModule(updatedKeys)` before applying an update and + // converts the cycle into a full reload (`__nsReloadDevApp`) on a hit. auto declineCb = [](const FunctionCallbackInfo& info) { - info.GetReturnValue().Set(v8::Undefined(info.GetIsolate())); + v8::Isolate* iso = info.GetIsolate(); + Local data = info.Data(); + std::string key; + if (!data.IsEmpty()) { v8::String::Utf8Value s(iso, data); key = *s ? *s : ""; } + if (!key.empty()) { + MarkHotDeclined(key); + if (tns::IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[import.meta.hot.decline] key=%s", key.c_str()); + } + } + info.GetReturnValue().Set(v8::Undefined(iso)); }; + // invalidate(message?) — request a full app reload. Per Vite spec this + // notifies the dev server; in NS we short-circuit to the runtime's + // `__nsReloadDevApp` global (which already does the invalidate + re-import + // dance). The optional `message` argument is logged. + // + // We invoke `__nsReloadDevApp` from a microtask so the user's current + // execution stack (which contains the `invalidate()` call site) finishes + // before the runtime tears down for reload — calling synchronously would + // try to re-bootstrap from inside an in-flight callback. auto invalidateCb = [](const FunctionCallbackInfo& info) { - info.GetReturnValue().Set(v8::Undefined(info.GetIsolate())); + v8::Isolate* iso = info.GetIsolate(); + Local data = info.Data(); + std::string key; + if (!data.IsEmpty()) { v8::String::Utf8Value s(iso, data); key = *s ? *s : ""; } + + std::string message; + if (info.Length() >= 1 && info[0]->IsString()) { + v8::String::Utf8Value m(iso, info[0]); + if (*m) message = *m; + } + if (tns::IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[import.meta.hot.invalidate] key=%s message=%s", + key.c_str(), message.empty() ? "(none)" : message.c_str()); + } + + v8::Local ctx = iso->GetCurrentContext(); + v8::Local global = ctx->Global(); + v8::Local reloadVal; + if (!global->Get(ctx, ToV8String(iso, "__nsReloadDevApp")).ToLocal(&reloadVal)) { + info.GetReturnValue().Set(v8::Undefined(iso)); + return; + } + if (!reloadVal->IsFunction()) { + // Older runtime / non-dev mode — silently no-op. Nothing else + // we can usefully do here. + info.GetReturnValue().Set(v8::Undefined(iso)); + return; + } + + // Defer the call via a resolved-promise microtask so we exit the + // current call stack before the reload tears the runtime down. Using + // microtasks rather than `setTimeout` keeps the deferral inside the + // same V8 microtask checkpoint — no event-loop delay, no UI hitch. + v8::Local reloadFn = reloadVal.As(); + v8::Local resolver; + if (v8::Promise::Resolver::New(ctx).ToLocal(&resolver)) { + v8::Local deferred = + v8::Function::New(ctx, [](const FunctionCallbackInfo& innerInfo) { + v8::Isolate* innerIso = innerInfo.GetIsolate(); + v8::Local innerCtx = innerIso->GetCurrentContext(); + v8::Local innerGlobal = innerCtx->Global(); + v8::Local reloadVal; + if (!innerGlobal->Get(innerCtx, ToV8String(innerIso, "__nsReloadDevApp")).ToLocal(&reloadVal)) return; + if (!reloadVal->IsFunction()) return; + v8::Local reloadFn = reloadVal.As(); + v8::TryCatch tc(innerIso); + (void)reloadFn->Call(innerCtx, v8::Undefined(innerIso), 0, nullptr); + // Reload is a fire-and-forget Promise on its own. Per-call + // failures aren't surfaced — they're not actionable from + // user code. + }).ToLocalChecked(); + v8::Local p = resolver->GetPromise(); + v8::MaybeLocal chained = p->Then(ctx, deferred); + (void)chained; + (void)resolver->Resolve(ctx, v8::Undefined(iso)); + } else { + // Promise machinery unavailable — fall back to a synchronous call. + // The user's current call stack will be torn down mid-execution + // but the user already requested a full reload, so that's + // acceptable. + v8::TryCatch tc(iso); + (void)reloadFn->Call(ctx, v8::Undefined(iso), 0, nullptr); + } + + info.GetReturnValue().Set(v8::Undefined(iso)); + }; + + // on(event, cb) — register custom event listener + auto onCb = [](const FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + if (info.Length() < 2) { + info.GetReturnValue().Set(v8::Undefined(iso)); + return; + } + if (!info[0]->IsString() || !info[1]->IsFunction()) { + info.GetReturnValue().Set(v8::Undefined(iso)); + return; + } + v8::String::Utf8Value eventName(iso, info[0]); + std::string event = *eventName ? *eventName : ""; + if (!event.empty()) { + RegisterHotEventListener(iso, event, info[1].As()); + } + info.GetReturnValue().Set(v8::Undefined(iso)); + }; + + // off(event, cb) — counterpart to `on`. Removes a previously-registered + // listener (matched by V8 strict equality on the Function reference). + auto offCb = [](const FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + if (info.Length() < 2) { + info.GetReturnValue().Set(v8::Undefined(iso)); + return; + } + if (!info[0]->IsString() || !info[1]->IsFunction()) { + info.GetReturnValue().Set(v8::Undefined(iso)); + return; + } + v8::String::Utf8Value eventName(iso, info[0]); + std::string event = *eventName ? *eventName : ""; + if (!event.empty()) { + RemoveHotEventListener(iso, event, info[1].As()); + } + info.GetReturnValue().Set(v8::Undefined(iso)); + }; + + // send(event, data) — send a custom message to the dev server. The runtime + // intentionally does not own a WebSocket; it delegates to a JS-installed + // `globalThis.__nsHmrSendToServer(event, data)` so the WebSocket-owning + // JS layer (typically @nativescript/vite's HMR client) keeps sole + // responsibility for transport. If no JS-side handler is installed (older + // HMR clients, non-dev mode) this is a clean no-op. + auto sendCb = [](const FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::Local ctx = iso->GetCurrentContext(); + v8::Local global = ctx->Global(); + v8::Local handlerVal; + if (!global->Get(ctx, ToV8String(iso, "__nsHmrSendToServer")).ToLocal(&handlerVal)) { + info.GetReturnValue().Set(v8::Undefined(iso)); + return; + } + if (!handlerVal->IsFunction()) { + info.GetReturnValue().Set(v8::Undefined(iso)); + return; + } + v8::Local handler = handlerVal.As(); + + // Forward `(event, data)` exactly as called. We don't enforce types on + // `event` (Vite spec only specifies the first arg as a string but + // implementations let it be coerced) and we pass `data` through + // verbatim — JS-side serialization is the transport's concern. + int argc = info.Length(); + if (argc > 2) argc = 2; + std::vector> args; + args.reserve(argc); + for (int i = 0; i < argc; ++i) args.push_back(info[i]); + + v8::TryCatch tc(iso); + (void)handler->Call(ctx, v8::Undefined(iso), argc, args.data()); + if (tc.HasCaught() && tns::IsScriptLoadingLogEnabled()) { + v8::Local ex = tc.Exception(); + v8::String::Utf8Value m(iso, ex); + DEBUG_WRITE("[import.meta.hot.send] handler threw: %s", *m ? *m : "(unknown)"); + } + info.GetReturnValue().Set(v8::Undefined(iso)); }; Local hot = Object::New(isolate); - hot->CreateDataProperty(context, ArgConverter::ConvertToV8String(isolate, "data"), - GetOrCreateHotData(isolate, modulePath)).Check(); - hot->CreateDataProperty(context, ArgConverter::ConvertToV8String(isolate, "prune"), - v8::Boolean::New(isolate, false)).Check(); + // Stable flags + hot->CreateDataProperty(context, ToV8String(isolate, "data"), + GetOrCreateHotData(isolate, key)).Check(); + // Methods + hot->CreateDataProperty( + context, ToV8String(isolate, "accept"), + v8::Function::New(context, acceptCb, makeKeyData(key)).ToLocalChecked()).Check(); + hot->CreateDataProperty( + context, ToV8String(isolate, "dispose"), + v8::Function::New(context, disposeCb, makeKeyData(key)).ToLocalChecked()).Check(); hot->CreateDataProperty( - context, ArgConverter::ConvertToV8String(isolate, "accept"), - v8::Function::New(context, acceptCb, makeKeyData(modulePath)).ToLocalChecked()).Check(); + context, ToV8String(isolate, "prune"), + v8::Function::New(context, pruneCb, makeKeyData(key)).ToLocalChecked()).Check(); hot->CreateDataProperty( - context, ArgConverter::ConvertToV8String(isolate, "dispose"), - v8::Function::New(context, disposeCb, makeKeyData(modulePath)).ToLocalChecked()).Check(); + context, ToV8String(isolate, "decline"), + v8::Function::New(context, declineCb, makeKeyData(key)).ToLocalChecked()).Check(); hot->CreateDataProperty( - context, ArgConverter::ConvertToV8String(isolate, "decline"), - v8::Function::New(context, declineCb, makeKeyData(modulePath)).ToLocalChecked()).Check(); + context, ToV8String(isolate, "invalidate"), + v8::Function::New(context, invalidateCb, makeKeyData(key)).ToLocalChecked()).Check(); hot->CreateDataProperty( - context, ArgConverter::ConvertToV8String(isolate, "invalidate"), - v8::Function::New(context, invalidateCb, makeKeyData(modulePath)).ToLocalChecked()).Check(); + context, ToV8String(isolate, "on"), + v8::Function::New(context, onCb, makeKeyData(key)).ToLocalChecked()).Check(); + hot->CreateDataProperty( + context, ToV8String(isolate, "off"), + v8::Function::New(context, offCb, makeKeyData(key)).ToLocalChecked()).Check(); + hot->CreateDataProperty( + context, ToV8String(isolate, "send"), + v8::Function::New(context, sendCb, makeKeyData(key)).ToLocalChecked()).Check(); + + // Attach to import.meta + importMeta->CreateDataProperty( + context, ToV8String(isolate, "hot"), + hot).Check(); +} + +// ───────────────────────────────────────────────────────────── +// HTTP loader helpers + speculative module prefetcher. +// +// The cache lives in `g_prefetchCache`, populated by background fetch +// threads (`std::thread` + `std::condition_variable` for concurrency +// gating). `HttpFetchText` checks the cache first (a "destructive read": +// consumed entries are erased) and only falls through to a fresh JNI +// HTTP fetch on a miss. +// +// Two flavours of kickstart drive the cache: +// - `KickstartHmrPrefetchSync(seed)` — cold-boot BFS over static +// imports, recursively widening from a seed URL until the wave +// drains or `timeoutSeconds` elapses. +// - `KickstartHmrPrefetchUrlsSync(urls)` — HMR-driven parallel +// fetch for a precomputed inverse-dep closure (e.g. `evictPaths` +// from a dev-server save message). No graph walk: server already +// told us the exact set to refresh. +// +// `RegisterHttpFetchYield` exposes a pluggable "yield to host" hook +// called from inside `KickstartRunSync`'s wait loop. The default is a no-op; +// embedders can install their own pump to keep the UI responsive. + +namespace { + +// Cap how many import specifiers we honour per module on the BFS, and +// how large a body we'll scan at all. Pretty-printed bundler output +// can easily blow past both — at that point we're better off paying +// the network on demand than parsing the giant string twice. +constexpr size_t kPrefetchMaxImportsPerModule = 256; +constexpr size_t kPrefetchMaxScanBytes = 2 * 1024 * 1024; // 2 MiB + +std::mutex g_prefetchMutex; +auto* _g_prefetchCache = new std::unordered_map(); +auto& g_prefetchCache = *_g_prefetchCache; + +inline bool IsHorizontalWs(char c) { return c == ' ' || c == '\t'; } +inline bool IsIdentifierChar(unsigned char c) { + return std::isalnum(c) || c == '_' || c == '$'; +} +inline char PreviousNonHwsChar(const std::string& s, size_t pos) { + if (pos == 0) return 0; + ssize_t i = static_cast(pos) - 1; + while (i >= 0 && IsHorizontalWs(s[i])) --i; + if (i < 0) return 0; + return s[i]; +} + +bool LooksLikeJsSourceUrl(const std::string& url) { + size_t qpos = url.find('?'); + std::string path = (qpos == std::string::npos) ? url : url.substr(0, qpos); + // Block clearly non-JS content; on cache hit V8 would attempt to compile + // CSS/images/etc. as ES modules and fail in confusing ways. + if (tns::EndsWith(path, ".css") || tns::EndsWith(path, ".scss") || + tns::EndsWith(path, ".sass") || tns::EndsWith(path, ".less")) return false; + if (tns::EndsWith(path, ".png") || tns::EndsWith(path, ".jpg") || + tns::EndsWith(path, ".jpeg") || tns::EndsWith(path, ".gif") || + tns::EndsWith(path, ".svg") || tns::EndsWith(path, ".webp") || + tns::EndsWith(path, ".ico")) return false; + if (tns::EndsWith(path, ".json")) return false; + if (tns::EndsWith(path, ".html") || tns::EndsWith(path, ".htm")) return false; + if (tns::EndsWith(path, ".woff") || tns::EndsWith(path, ".woff2") || + tns::EndsWith(path, ".ttf") || tns::EndsWith(path, ".otf") || + tns::EndsWith(path, ".eot")) return false; + if (tns::EndsWith(path, ".mp4") || tns::EndsWith(path, ".webm") || + tns::EndsWith(path, ".mp3") || tns::EndsWith(path, ".wav")) return false; + return true; +} + +// Two-pass scan over a module body to extract its static-import URLs: +// Pass 1: `... from ""` (covers all import-from forms, including +// default, namespace, named, side-effect re-exports). +// Pass 2: `import ""` (side-effect imports). +// Dynamic imports (`import(…)`) and `.from(…)` member access are +// explicitly rejected — accepting them would feed us too many false +// positives that dilute the BFS budget. +std::vector ScanStaticImportSpecifiers(const std::string& source, size_t maxResults) { + std::vector result; + if (source.size() > kPrefetchMaxScanBytes) return result; + std::unordered_set seen; + result.reserve(16); + + auto captureSpecAfter = [&](size_t cursor) -> ssize_t { + while (cursor < source.size()) { + char c = source[cursor]; + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { cursor++; continue; } + break; + } + if (cursor >= source.size()) return -1; + char quote = source[cursor]; + if (quote != '"' && quote != '\'' && quote != '`') return -1; + size_t end = source.find(quote, cursor + 1); + if (end == std::string::npos) return -1; + std::string spec = source.substr(cursor + 1, end - cursor - 1); + if (!spec.empty() && spec.find('\n') == std::string::npos && seen.insert(spec).second) { + result.push_back(std::move(spec)); + } + return static_cast(end + 1); + }; + + { + const char* needle = "from"; + const size_t needleLen = 4; + size_t pos = 0; + while (pos < source.size() && result.size() < maxResults) { + size_t hit = source.find(needle, pos); + if (hit == std::string::npos) break; + if (hit > 0 && IsIdentifierChar(static_cast(source[hit - 1]))) { pos = hit + 1; continue; } + size_t after = hit + needleLen; + if (after < source.size() && IsIdentifierChar(static_cast(source[after]))) { pos = hit + 1; continue; } + char prev = PreviousNonHwsChar(source, hit); + bool ok = (prev == '}' || prev == '*' || prev == ',' || + IsIdentifierChar(static_cast(prev))); + if (!ok) { pos = hit + 1; continue; } + ssize_t adv = captureSpecAfter(after); + if (adv < 0) { pos = hit + 1; continue; } + pos = static_cast(adv); + } + } + { + const char* needle = "import"; + const size_t needleLen = 6; + size_t pos = 0; + while (pos < source.size() && result.size() < maxResults) { + size_t hit = source.find(needle, pos); + if (hit == std::string::npos) break; + if (hit > 0 && IsIdentifierChar(static_cast(source[hit - 1]))) { pos = hit + 1; continue; } + size_t after = hit + needleLen; + if (after < source.size() && IsIdentifierChar(static_cast(source[after]))) { pos = hit + 1; continue; } + char prev = PreviousNonHwsChar(source, hit); + bool atStmtStart = (prev == 0 || prev == '\n' || prev == '\r' || prev == ';' || prev == '}'); + if (!atStmtStart) { pos = hit + 1; continue; } + size_t cursor = after; + while (cursor < source.size() && IsHorizontalWs(source[cursor])) cursor++; + if (cursor >= source.size()) break; + char next = source[cursor]; + if (next == '(') { pos = hit + 1; continue; } + if (next != '"' && next != '\'' && next != '`') { pos = hit + 1; continue; } + ssize_t adv = captureSpecAfter(cursor); + if (adv < 0) { pos = hit + 1; continue; } + pos = static_cast(adv); + } + } + return result; +} + +} // anonymous namespace + +// Resolve a relative/root-absolute import specifier against a parent URL +// using plain string manipulation. Only relative (`./`, `../`) and +// root-absolute (`/`) specifiers are resolved here; bare specifiers and +// already-absolute URLs fall through unchanged. Exposed via +// `HMRSupport.h` so `ModuleInternalCallbacks.cpp` can share this single +// resolver instead of carrying its own copy. +std::string ResolveImportSpecifierAgainstUrl(const std::string& specifier, + const std::string& parentUrl) { + if (specifier.empty()) return ""; + // Already absolute. + if (tns::StartsWith(specifier, "http://") || tns::StartsWith(specifier, "https://")) { + return specifier; + } + bool isRelative = tns::StartsWith(specifier, "./") || tns::StartsWith(specifier, "../"); + bool isRootAbs = !specifier.empty() && specifier[0] == '/'; + if (!isRelative && !isRootAbs) return ""; + + if (!(tns::StartsWith(parentUrl, "http://") || tns::StartsWith(parentUrl, "https://"))) { + return ""; + } + // Drop fragment + query from parent. + std::string base = parentUrl; + size_t hp = base.find('#'); if (hp != std::string::npos) base = base.substr(0, hp); + size_t qp = base.find('?'); if (qp != std::string::npos) base = base.substr(0, qp); + + size_t schemePos = base.find("://"); + if (schemePos == std::string::npos) return ""; + size_t pathStart = base.find('/', schemePos + 3); + std::string origin = (pathStart == std::string::npos) ? base : base.substr(0, pathStart); + std::string path = (pathStart == std::string::npos) ? std::string("/") : base.substr(pathStart); + + std::string specPath = specifier; + std::string suffix; + size_t specQ = specPath.find('?'); + size_t specH = specPath.find('#'); + size_t cut = std::string::npos; + if (specQ != std::string::npos && specH != std::string::npos) cut = std::min(specQ, specH); + else if (specQ != std::string::npos) cut = specQ; + else if (specH != std::string::npos) cut = specH; + if (cut != std::string::npos) { suffix = specPath.substr(cut); specPath = specPath.substr(0, cut); } + + std::string newPath; + if (isRootAbs) { + newPath = specPath; + } else { + size_t lastSlash = path.find_last_of('/'); + std::string baseDir = (lastSlash == std::string::npos) ? std::string("/") : path.substr(0, lastSlash + 1); + newPath = baseDir + specPath; + } + // Normalize `.` and `..` segments. + std::vector stack; + bool absolute = !newPath.empty() && newPath[0] == '/'; + size_t i = 0; + while (i <= newPath.size()) { + size_t j = newPath.find('/', i); + std::string seg = (j == std::string::npos) ? newPath.substr(i) : newPath.substr(i, j - i); + if (seg.empty() || seg == ".") { + // skip + } else if (seg == "..") { + if (!stack.empty()) stack.pop_back(); + } else { + stack.push_back(seg); + } + if (j == std::string::npos) break; + i = j + 1; + } + std::string norm = absolute ? "/" : std::string(); + for (size_t k = 0; k < stack.size(); ++k) { + if (k > 0) norm += "/"; + norm += stack[k]; + } + return origin + norm + suffix; +} + +namespace { + +// Pluggable host yield. Default: no-op. Embedders that want a JS-thread +// runloop pump during cold-boot fetches can install one via +// `RegisterHttpFetchYield` (e.g. ALooper_pollOnce(0)). +void NoopHttpFetchYield() {} +std::atomic g_httpFetchYield{&NoopHttpFetchYield}; - importMeta->CreateDataProperty(context, ArgConverter::ConvertToV8String(isolate, "hot"), hot).Check(); +inline void InvokeHttpFetchYield() { + auto cb = g_httpFetchYield.load(std::memory_order_acquire); + if (cb != nullptr) cb(); } +} // anonymous namespace + +void RegisterHttpFetchYield(void (*callback)()) { + g_httpFetchYield.store(callback, std::memory_order_release); +} + +void ClearHttpModulePrefetchCache() { + std::lock_guard lock(g_prefetchMutex); + g_prefetchCache.clear(); +} + +void EvictHttpModulePrefetchCacheUrls(const std::vector& urls) { + if (urls.empty()) return; + std::lock_guard lock(g_prefetchMutex); + size_t hits = 0; + for (const std::string& u : urls) { + auto it = g_prefetchCache.find(u); + if (it != g_prefetchCache.end()) { g_prefetchCache.erase(it); ++hits; } + } + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[prefetch][evict] urls=%lu hits=%lu remaining=%lu", + (unsigned long)urls.size(), (unsigned long)hits, + (unsigned long)g_prefetchCache.size()); + } +} + +// ───────────────────────────────────────────────────────────── +// HTTP loader helpers (the speculative-prefetcher additions live above). + // Drop fragments and normalize parameters for consistent registry keys. std::string CanonicalizeHttpUrlKey(const std::string& url) { - if (!(StartsWith(url, "http://") || StartsWith(url, "https://"))) { - return url; + // Some loaders wrap HTTP module URLs as file://http(s)://... + std::string normalizedUrl = url; + if (StartsWith(normalizedUrl, "file://http://") || StartsWith(normalizedUrl, "file://https://")) { + normalizedUrl = normalizedUrl.substr(strlen("file://")); + } + if (!(StartsWith(normalizedUrl, "http://") || StartsWith(normalizedUrl, "https://"))) { + return normalizedUrl; } // Remove fragment - size_t hashPos = url.find('#'); - std::string noHash = (hashPos == std::string::npos) ? url : url.substr(0, hashPos); + size_t hashPos = normalizedUrl.find('#'); + std::string noHash = (hashPos == std::string::npos) ? normalizedUrl : normalizedUrl.substr(0, hashPos); // Split into origin+path and query size_t qPos = noHash.find('?'); @@ -208,30 +1662,282 @@ std::string CanonicalizeHttpUrlKey(const std::string& url) { if (i > 0) rebuilt += "&"; rebuilt += kept[i]; } - return rebuilt; + return rebuilt; +} + +// Thread-local capture of the most recent JNI-level fetch failure +// (e.g. `ConnectException: failed to connect to /10.0.2.2 (port 5173) +// after 15000ms: connect failed: ECONNREFUSED`). Callers that just +// got back `status=0` from `HttpFetchText` can pull this string and +// splice it into the JS error to give users actionable detail rather +// than a generic "Failed to fetch" line. +// +// Thread-local because each V8 isolate / worker has its own JS +// thread, and concurrent fetches would otherwise clobber each +// other's diagnostic. +static thread_local std::string g_lastHttpFetchErrorReason; + +static void RecordLastHttpFetchError(const char* stage, + const std::string& excClass, + const std::string& excMsg) { + // Format is grep-friendly and short enough to splice into a JS + // Error message without exploding the line length: + // stage=get-response-code class=java.net.ConnectException msg=... + g_lastHttpFetchErrorReason.assign("stage="); + g_lastHttpFetchErrorReason.append(stage ? stage : "?"); + g_lastHttpFetchErrorReason.append(" class="); + g_lastHttpFetchErrorReason.append(excClass); + g_lastHttpFetchErrorReason.append(" msg="); + g_lastHttpFetchErrorReason.append(excMsg); +} + +void ClearLastHttpFetchErrorReason() { + g_lastHttpFetchErrorReason.clear(); +} + +std::string TakeLastHttpFetchErrorReason() { + std::string out = std::move(g_lastHttpFetchErrorReason); + g_lastHttpFetchErrorReason.clear(); + return out; +} + +// Decide whether a captured fetch failure reason looks like the +// transient okhttp / socket-pool class of bug that one retry on a +// fresh connection reliably clears. +// +// The list is deliberately narrow. Hard failures like `ConnectException`, +// `UnknownHostException`, `MalformedURLException`, or the runtime's own +// security gate (`status=403`) are NOT retryable — retrying would only mask +// config bugs (wrong host/port, missing allowlist entry) behind extra latency. +// +// Patterns covered: +// * `unexpected end of stream` — server closed the socket mid-handshake +// (Http1xStream.readResponseHeaders). +// * `Connection reset` / `SocketException` / `EOFException` — same root +// cause, different surfacing depending on when the RST/FIN landed. +// * `Software caused connection abort` — Android/Linux variant, seen on +// emulators under load. +// * `Stream closed` / `StreamResetException` — HTTP/2 codepath (some reverse +// proxies upgrade the tunnel to h2). +static bool IsRetryableFetchReason(const std::string& reason) { + if (reason.find("unexpected end of stream") != std::string::npos) return true; + if (reason.find("Connection reset") != std::string::npos) return true; + if (reason.find("Software caused connection abort") != std::string::npos) return true; + if (reason.find("EOFException") != std::string::npos) return true; + if (reason.find("SocketException") != std::string::npos) return true; + if (reason.find("StreamResetException") != std::string::npos) return true; + if (reason.find("Stream closed") != std::string::npos) return true; + return false; +} + +// Raw JNI fetch — no cache lookup, no allowlist gate. Used by the +// background prefetch threads (which already pre-filtered URLs) so the +// public `HttpFetchText` can keep its allowlist-and-cache logic in one +// place without recursing into itself. Returns true on success (2xx, +// non-empty body). +static bool PerformHttpFetchOnceSync(const std::string& url, + std::string& out, + std::string& contentType, + int& status); + +// If a Java exception is pending, drain it into `outClassName` / +// `outMessage` and clear it so subsequent JNI calls don't ABORT the +// process. Returns true when an exception was actually present. +// +// We grab both the simple class name (e.g. `ConnectException`) and +// the `toString()` payload because the latter often includes the +// underlying OS errno (`Connection refused`, `Network unreachable`, +// `failed to connect to /10.0.2.2 (port 5173)` etc.) — exactly the +// diagnostic the silent `status=0` symptom hides. +static bool DrainPendingJniException(JEnv& env, std::string& outClassName, std::string& outMessage) { + outClassName.clear(); + outMessage.clear(); + jthrowable th = env.ExceptionOccurred(); + if (!th) return false; + env.ExceptionClear(); + + jclass clsThrowable = env.GetObjectClass(th); + if (clsThrowable) { + jclass clsClass = env.FindClass("java/lang/Class"); + if (clsClass) { + jmethodID getName = env.GetMethodID(clsClass, "getName", "()Ljava/lang/String;"); + if (getName) { + jstring jName = static_cast(env.CallObjectMethod(clsThrowable, getName)); + env.ExceptionClear(); + if (jName) { + outClassName = ArgConverter::jstringToString(jName); + } + } + } + jmethodID toString = env.GetMethodID(clsThrowable, "toString", "()Ljava/lang/String;"); + if (toString) { + jstring jMsg = static_cast(env.CallObjectMethod(th, toString)); + env.ExceptionClear(); + if (jMsg) { + outMessage = ArgConverter::jstringToString(jMsg); + } + } + } + env.ExceptionClear(); + return true; +} + +// Minimal HTTP fetch using java.net.* via JNI. Returns true on success (2xx) and non-empty body. +// Security: This is the single point of enforcement for remote module loading. +// In debug mode, all URLs are allowed. In production, checks security.allowRemoteModules +// and security.remoteModuleAllowlist from the app config. +bool HttpFetchText(const std::string& url, std::string& out, std::string& contentType, int& status) { + out.clear(); + contentType.clear(); + status = 0; + // Start each fetch with a clean diagnostic slot so a successful + // fetch can't leave a stale reason from a previous failure. + ClearLastHttpFetchErrorReason(); + + // Security gate: check if remote module loading is allowed before any HTTP fetch. + if (!IsRemoteUrlAllowed(url)) { + status = 403; + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[http-esm][security][blocked] %s", url.c_str()); + } + return false; + } + + // Speculative-prefetch cache check (destructive read). + // + // Honoured only when the opt-in prefetcher is enabled (package.json + // "httpModulePrefetch", default false). When disabled, the prefetch wave + // never populates the cache (see KickstartHmrPrefetch*Sync) AND this read + // is skipped, restoring the pre-prefetcher fetch behavior bit-for-bit. + // Volatility is enforced upstream by `EvictHttpModulePrefetchCacheUrls` on + // the eviction set rather than by gating reads here. Consuming the entry on + // hit guarantees that a re-fetch after HMR goes back to the network for + // fresh source. + if (IsHttpModulePrefetchEnabled()) { + std::string cached; + bool cacheHit = false; + { + std::lock_guard lock(g_prefetchMutex); + auto it = g_prefetchCache.find(url); + if (it != g_prefetchCache.end()) { + cached = std::move(it->second); + g_prefetchCache.erase(it); + cacheHit = true; + } + } + if (cacheHit) { + out = std::move(cached); + contentType = "application/javascript"; + status = 200; + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[http-loader][prefetch][hit] %s (%lu bytes)", url.c_str(), (unsigned long)out.size()); + } + // Yield to the host between back-to-back cache hits so any + // installed heartbeat/runloop pump gets a turn. + InvokeHttpFetchYield(); + return true; + } + } + + // Slow path: synchronous fetch with bounded retry on transient okhttp-class + // failures. Android's stock HttpURLConnection (backed by okhttp) periodically + // half-recycles sockets when the server's keep-alive timeout fires before the + // next request lands; the next use of that socket throws + // `IOException: unexpected end of stream` from + // `Http1xStream.readResponseHeaders`. It manifests as random per-request + // failures across modules on cold boot, and a fresh connection on the next + // attempt succeeds. + // + // `Connection: close` prevents okhttp from pooling our own connection but + // doesn't help when the server already poisoned the pool from an earlier + // in-flight fetch. The retry covers both cases, and the system-wide + // `http.keepAlive=false` set inside `PerformHttpFetchOnceSync` keeps okhttp + // from pooling in the first place. + constexpr int kMaxAttempts = 3; + for (int attempt = 1; attempt <= kMaxAttempts; ++attempt) { + // Clear the slot at the start of each attempt so a previous + // attempt's reason can't leak into a later one. + ClearLastHttpFetchErrorReason(); + if (PerformHttpFetchOnceSync(url, out, contentType, status)) { + if (attempt > 1 && IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[http-esm][fetch][retry-ok] url=%s attempt=%d", url.c_str(), attempt); + } + return true; + } + std::string reason = TakeLastHttpFetchErrorReason(); + if (attempt >= kMaxAttempts || !IsRetryableFetchReason(reason)) { + // Re-stash so the caller sees the same reason we just consumed. + if (!reason.empty()) { + RecordLastHttpFetchError("final-attempt", "captured", reason); + } + return false; + } + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[http-esm][fetch][retry] url=%s attempt=%d/%d reason=%s", + url.c_str(), attempt, kMaxAttempts, reason.c_str()); + } + // Short linear backoff. A stale pooled socket only needs one + // tick to clear; longer waits would just add cold-boot latency + // on what's typically dozens of static imports. + std::this_thread::sleep_for(std::chrono::milliseconds(25 * attempt)); + } + return false; } -// Minimal HTTP fetch using java.net.* via JNI. Returns true on success (2xx) and non-empty body. -// Security: This is the single point of enforcement for remote module loading. -// In debug mode, all URLs are allowed. In production, checks security.allowRemoteModules -// and security.remoteModuleAllowlist from the app config. -bool HttpFetchText(const std::string& url, std::string& out, std::string& contentType, int& status) { +// True raw HTTP fetch path. See PerformHttpFetchOnceSync forward +// declaration above for purpose. We extracted this from HttpFetchText +// so the prefetcher (which already filtered URLs and intends to +// populate the cache) doesn't re-check the cache itself. +static bool PerformHttpFetchOnceSync(const std::string& url, + std::string& out, + std::string& contentType, + int& status) { out.clear(); contentType.clear(); status = 0; - - // Security gate: check if remote module loading is allowed before any HTTP fetch. - if (!IsRemoteUrlAllowed(url)) { - status = 403; // Forbidden - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][security][blocked] %s", url.c_str()); - } - return false; + // Entry trace gated behind `logScriptLoading`. Cold boot fires this dozens of + // times per session, so it stays off by default; enable `logScriptLoading` + // when triaging the HTTP-ESM path. + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[http-esm][fetch][enter] url=%s", url.c_str()); } - try { JEnv env; + // One-time process-wide kill switch for okhttp's connection + // pool. Android's stock HttpURLConnection (which okhttp backs) + // pools sockets across requests, and when Vite's keep-alive + // timeout fires before our next request we end up reusing a + // dead socket and hitting + // `IOException: unexpected end of stream`. Setting + // `http.keepAlive=false` forces a fresh TCP connection per + // fetch, which sidesteps the pool entirely. + // + // We also set `Connection: close` on the per-request headers + // below as a belt-and-suspenders signal — the property covers + // the pool, the header covers the wire. Doing this once via + // an atomic guard keeps the cost out of the hot path on + // repeat fetches (this is a synchronous V8 module-loader + // hot path that can fire dozens of times per cold boot). + static std::atomic sKeepAliveDisabled{false}; + if (!sKeepAliveDisabled.exchange(true)) { + jclass clsSystem = env.FindClass("java/lang/System"); + if (clsSystem) { + jmethodID setProperty = env.GetStaticMethodID( + clsSystem, "setProperty", + "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;"); + if (setProperty) { + jstring jKey = env.NewStringUTF("http.keepAlive"); + jstring jVal = env.NewStringUTF("false"); + env.CallStaticObjectMethod(clsSystem, setProperty, jKey, jVal); + // Don't care about the previous value or about exceptions + // here — `System.setProperty` only throws SecurityException + // under a SecurityManager and Android apps don't install one. + env.ExceptionClear(); + } + } + } + // Allow network operations on the current thread (dev-only HMR path) // Some Android environments enforce StrictMode which throws NetworkOnMainThreadException // when performing network I/O on the main thread. Since this fetch runs on the JS/V8 thread @@ -264,7 +1970,36 @@ bool HttpFetchText(const std::string& url, std::string& out, std::string& conten jstring jUrlStr = env.NewStringUTF(url.c_str()); jobject urlObj = env.NewObject(clsURL, urlCtor, jUrlStr); - jobject conn = env.CallObjectMethod(urlObj, openConnection); + // `URL` ctor throws MalformedURLException on bad input. Drain it + // so we can blame the right thing in logs rather than the silent + // path below. + { + std::string excClass, excMsg; + if (DrainPendingJniException(env, excClass, excMsg)) { + RecordLastHttpFetchError("url-ctor", excClass, excMsg); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[http-esm][fetch][exception] stage=url-ctor url=%s class=%s msg=%s", + url.c_str(), excClass.c_str(), excMsg.c_str()); + } + return false; + } + } + + jobject conn = env.CallObjectMethod(urlObj, openConnection); + // `URL.openConnection()` can throw IOException for unsupported + // protocols or proxy lookup failures. Capture the message before + // it gets eaten by the bare `return false`. + { + std::string excClass, excMsg; + if (DrainPendingJniException(env, excClass, excMsg)) { + RecordLastHttpFetchError("open-connection", excClass, excMsg); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[http-esm][fetch][exception] stage=open-connection url=%s class=%s msg=%s", + url.c_str(), excClass.c_str(), excMsg.c_str()); + } + return false; + } + } if (!conn) return false; jclass clsConn = env.GetObjectClass(conn); @@ -290,6 +2025,19 @@ bool HttpFetchText(const std::string& url, std::string& out, std::string& conten jmethodID getErrorStream = isHttp ? env.GetMethodID(clsHttp, "getErrorStream", "()Ljava/io/InputStream;") : nullptr; if (isHttp && getResponseCode) { status = env.CallIntMethod(conn, getResponseCode); + // `getResponseCode()` is the call that actually performs the TCP + // connect — so this is where ConnectException / SocketTimeout / + // UnknownHost surface. Drain the exception here so it doesn't return + // as a bare `status=0`. + std::string excClass, excMsg; + if (DrainPendingJniException(env, excClass, excMsg)) { + RecordLastHttpFetchError("get-response-code", excClass, excMsg); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[http-esm][fetch][exception] stage=get-response-code url=%s class=%s msg=%s", + url.c_str(), excClass.c_str(), excMsg.c_str()); + } + return false; + } } // Read InputStream (prefer error stream on HTTP error codes) @@ -301,6 +2049,20 @@ bool HttpFetchText(const std::string& url, std::string& out, std::string& conten if (!inStream) { inStream = env.CallObjectMethod(conn, getInputStream); } + // `getInputStream()` is the second place a connect failure surfaces + // (when the previous `getResponseCode` path didn't trigger it, + // e.g. for non-HTTP URLConnection subclasses). + { + std::string excClass, excMsg; + if (DrainPendingJniException(env, excClass, excMsg)) { + RecordLastHttpFetchError("get-input-stream", excClass, excMsg); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[http-esm][fetch][exception] stage=get-input-stream url=%s class=%s msg=%s", + url.c_str(), excClass.c_str(), excMsg.c_str()); + } + return false; + } + } if (!inStream) return false; jclass clsIS = env.GetObjectClass(inStream); @@ -345,9 +2107,904 @@ bool HttpFetchText(const std::string& url, std::string& out, std::string& conten if (status == 0) status = 200; // assume OK if not HTTP return status >= 200 && status < 300 && !out.empty(); + } catch (NativeScriptException& nse) { + // `JEnv::CheckForJavaException()` converts any pending Java + // exception into a `NativeScriptException` and rethrows on the + // C++ side. Because JEnv has already called `ExceptionClear`, + // `DrainPendingJniException` at the JNI call sites above sees + // nothing — the only place the original Java message survives + // is on this exception object. So we record it here too, + // covering both the wrapped JNI calls (`env.GetMethodID`, + // `env.CallVoidMethod`, etc.) and the raw `m_env->` calls in + // the same try block. + std::string what = nse.what() ? nse.what() : ""; + if (what.empty()) { + what = nse.GetErrorMessage(); + } + RecordLastHttpFetchError("native-script-exception", "tns::NativeScriptException", what); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[http-esm][fetch][exception] stage=native-script-exception url=%s msg=%s", + url.c_str(), what.c_str()); + } + return false; + } catch (std::exception& ex) { + std::string what = ex.what() ? ex.what() : ""; + RecordLastHttpFetchError("std-exception", "std::exception", what); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[http-esm][fetch][exception] stage=std-exception url=%s msg=%s", + url.c_str(), what.c_str()); + } + return false; + } catch (...) { + RecordLastHttpFetchError("unknown-cpp-exception", "", ""); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[http-esm][fetch][exception] stage=unknown-cpp-exception url=%s", + url.c_str()); + } + return false; + } +} + +// ───────────────────────────────────────────────────────────── +// Kickstart prefetcher. +// +// `KickstartHmrPrefetchSync` does seed-rooted BFS; `KickstartHmrPrefetchUrlsSync` +// runs a parallel fetch wave over a pre-computed URL list. Both funnel +// through `KickstartRunSync` so the wait loop / metrics / logging are +// shared. Concurrency is bounded by a counting semaphore implemented +// with mutex + condition variable (NDKs do not yet ship the C++20 +// `std::counting_semaphore`). + +namespace { + +class CountingSemaphore { + public: + explicit CountingSemaphore(int initial) : count_(initial) {} + void Acquire() { + std::unique_lock lk(m_); + cv_.wait(lk, [this]{ return count_ > 0; }); + --count_; + } + void Release() { + { + std::lock_guard lk(m_); + ++count_; + } + cv_.notify_one(); + } + + private: + std::mutex m_; + std::condition_variable cv_; + int count_; +}; + +struct KickstartContext { + std::mutex mutex; + std::unordered_set visited; + std::atomic fetchedCount{0}; + std::atomic bytes{0}; + std::unique_ptr concurrency; + bool recursive = true; + + // Outstanding-work counter: each scheduled fetch increments + decrements a + // counter under a mutex, and the wait loop blocks on `cv.wait_for` for + // transitions to zero. + std::mutex pendingMutex; + std::condition_variable pendingCv; + int pending = 0; + + void EnterPending() { + std::lock_guard lk(pendingMutex); + ++pending; + } + void LeavePending() { + { + std::lock_guard lk(pendingMutex); + if (pending > 0) --pending; + } + pendingCv.notify_all(); + } + // Wait up to `sliceMs` for `pending == 0`. Returns true if drained. + bool WaitDrainSlice(int sliceMs) { + std::unique_lock lk(pendingMutex); + return pendingCv.wait_for(lk, std::chrono::milliseconds(sliceMs), + [this]{ return pending == 0; }); + } +}; + +void KickstartScheduleUrls(std::shared_ptr ctx, + std::vector urls) { + for (const std::string& urlRef : urls) { + if (urlRef.empty()) continue; + if (!StartsWith(urlRef, "http://") && !StartsWith(urlRef, "https://")) continue; + if (!LooksLikeJsSourceUrl(urlRef)) continue; + if (!IsRemoteUrlAllowed(urlRef)) continue; + + bool fresh; + { + std::lock_guard lock(ctx->mutex); + fresh = ctx->visited.insert(urlRef).second; + } + if (!fresh) continue; + + // In recursive (cold-boot BFS) mode, skip URLs already in the cache. + // In HMR mode (recursive=false) the caller has *explicitly* listed + // URLs to refresh — honoring an existing cache entry would feed V8 + // the stale body. So we skip this short-circuit when recursive=false. + if (ctx->recursive) { + std::lock_guard lock(g_prefetchMutex); + if (g_prefetchCache.find(urlRef) != g_prefetchCache.end()) continue; + } + + ctx->EnterPending(); + std::string urlCopy = urlRef; + const bool hmrMode = !ctx->recursive; + auto ctxCopy = ctx; + std::thread([ctxCopy, urlCopy, hmrMode]() { + ctxCopy->concurrency->Acquire(); + std::string body, contentType; + int status = 0; + bool ok = PerformHttpFetchOnceSync(urlCopy, body, contentType, status); + if (ok && status >= 200 && status < 300 && !body.empty()) { + size_t bodySize = body.size(); + std::string scanSource; + { + std::lock_guard lock(g_prefetchMutex); + if (hmrMode) { + // HMR: caller's URLs are by definition the authoritative copy. + // Overwrite unconditionally; any older cache entry is stale. + auto& slot = g_prefetchCache[urlCopy]; + slot = std::move(body); + scanSource = slot; + bodySize = slot.size(); + } else { + // Cold boot: insert-without-overwrite. Another path may have + // already landed this URL via opt-in speculative prefetch; + // honour whichever copy got there first. + auto inserted = g_prefetchCache.emplace(urlCopy, std::move(body)); + if (inserted.second) { + scanSource = inserted.first->second; + } else { + scanSource = inserted.first->second; + bodySize = inserted.first->second.size(); + } + } + } + ctxCopy->fetchedCount.fetch_add(1, std::memory_order_relaxed); + ctxCopy->bytes.fetch_add(bodySize, std::memory_order_relaxed); + if (ctxCopy->recursive) { + std::vector specs = + ScanStaticImportSpecifiers(scanSource, kPrefetchMaxImportsPerModule); + if (!specs.empty()) { + std::vector nextUrls; + nextUrls.reserve(specs.size()); + for (const std::string& spec : specs) { + std::string absUrl = ResolveImportSpecifierAgainstUrl(spec, urlCopy); + if (!absUrl.empty()) nextUrls.push_back(std::move(absUrl)); + } + if (!nextUrls.empty()) { + KickstartScheduleUrls(ctxCopy, std::move(nextUrls)); + } + } + } + } + ctxCopy->concurrency->Release(); + ctxCopy->LeavePending(); + }).detach(); + } +} + +bool KickstartRunSync(std::vector urls, int maxConcurrent, + double timeoutSeconds, bool recursive, const char* logLabel, + const std::string& diagSeed, size_t* outFetchedCount, + uint64_t* outElapsedMs) { + if (urls.empty()) return false; + + std::vector filtered; + filtered.reserve(urls.size()); + for (auto& u : urls) { + if (u.empty()) continue; + if (!IsRemoteUrlAllowed(u)) continue; + filtered.push_back(std::move(u)); + } + if (filtered.empty()) return false; + + if (maxConcurrent <= 0) maxConcurrent = 16; + if (timeoutSeconds <= 0.0) timeoutSeconds = 10.0; + + const auto start = std::chrono::steady_clock::now(); + + auto ctx = std::make_shared(); + ctx->concurrency = std::make_unique(maxConcurrent); + ctx->recursive = recursive; + + KickstartScheduleUrls(ctx, std::move(filtered)); + + // Wait loop. Uses a slice-based timeout so the host runloop (e.g. the + // JS-thread pump) gets a chance to drain between slices. 50ms is short + // enough to feel responsive and long enough to avoid spinning. + const int sliceMs = 50; + const auto deadline = start + std::chrono::milliseconds(static_cast(timeoutSeconds * 1000.0)); + bool drained = false; + while (true) { + drained = ctx->WaitDrainSlice(sliceMs); + if (drained) break; + if (std::chrono::steady_clock::now() >= deadline) break; + InvokeHttpFetchYield(); + } + + const auto end = std::chrono::steady_clock::now(); + const uint64_t elapsedMs = + std::chrono::duration_cast(end - start).count(); + const size_t fetched = ctx->fetchedCount.load(std::memory_order_relaxed); + const size_t bytes = ctx->bytes.load(std::memory_order_relaxed); + + if (outFetchedCount) *outFetchedCount = fetched; + if (outElapsedMs) *outElapsedMs = elapsedMs; + + if (IsScriptLoadingLogEnabled()) { + if (recursive) { + DEBUG_WRITE("[hmr-kickstart][%s] seed=%s fetched=%lu bytes=%lu ms=%llu status=%s concurrency=%d", + logLabel ? logLabel : "bfs", diagSeed.c_str(), + (unsigned long)fetched, (unsigned long)bytes, + (unsigned long long)elapsedMs, + drained ? "drained" : "timeout", maxConcurrent); + } else { + DEBUG_WRITE("[hmr-kickstart][%s] urls=%lu fetched=%lu bytes=%lu ms=%llu status=%s concurrency=%d", + logLabel ? logLabel : "list", (unsigned long)urls.size(), + (unsigned long)fetched, (unsigned long)bytes, + (unsigned long long)elapsedMs, + drained ? "drained" : "timeout", maxConcurrent); + } + } + return drained; +} + +} // anonymous namespace + +bool KickstartHmrPrefetchSync(const std::string& seedUrl, + int maxConcurrent, + double timeoutSeconds, + size_t* outFetchedCount, + uint64_t* outElapsedMs) { + if (seedUrl.empty()) return false; + // Opt-in gate (package.json "httpModulePrefetch", default false). Layered on + // top of the IsRemoteUrlAllowed network gate; when disabled, the speculative + // prefetch wave never runs. + if (!IsHttpModulePrefetchEnabled()) return false; + if (!IsRemoteUrlAllowed(seedUrl)) return false; + std::vector seeds{seedUrl}; + return KickstartRunSync(std::move(seeds), maxConcurrent, timeoutSeconds, + /*recursive=*/true, "bfs", seedUrl, + outFetchedCount, outElapsedMs); +} + +bool KickstartHmrPrefetchUrlsSync(const std::vector& urls, + int maxConcurrent, + double timeoutSeconds, + size_t* outFetchedCount, + uint64_t* outElapsedMs) { + if (urls.empty()) return false; + // Opt-in gate (package.json "httpModulePrefetch", default false). Per-URL + // network access is still gated by IsRemoteUrlAllowed inside KickstartRunSync. + if (!IsHttpModulePrefetchEnabled()) return false; + std::string diagSeed; + for (const auto& u : urls) { + if (!u.empty()) { diagSeed = u; break; } + } + return KickstartRunSync(std::vector(urls), maxConcurrent, + timeoutSeconds, /*recursive=*/false, "list", + diagSeed, outFetchedCount, outElapsedMs); +} + +// ───────────────────────────────────────────────────────────── +// HMR + dev-session JS-callable globals. +// +// Installs the JS-callable globals the @nativescript/vite HMR client and +// deterministic dev-session bootstrap rely on. `Runtime::RunModule(const +// char*)` returns `void` on Android rather than `bool`, so failures are +// surfaced by +// catching `NativeScriptException`). + +namespace { + +// Helper used by both `__nsConfigureRuntime` and the `__nsConfigureDevRuntime` +// alias to apply the dev-session config payload, so both entry points behave +// identically. +void ConfigureDevRuntimeCallback(const v8::FunctionCallbackInfo& info) { + v8::Isolate* isolate = info.GetIsolate(); + v8::HandleScope scope(isolate); + v8::Local ctx = isolate->GetCurrentContext(); + bool logScriptLoading = tns::IsScriptLoadingLogEnabled(); + + // Defense in depth: never mutate the process-wide import map / volatile + // patterns in a release build. The install site (Runtime::PrepareV8Runtime) + // is already debug-gated, so this only fires if that gate is bypassed. + if (!tns::IsDebuggable()) { + return; + } + + if (info.Length() < 1 || !info[0]->IsObject()) { + if (logScriptLoading) { + DEBUG_WRITE("[__nsConfigureRuntime] expected config object argument"); + } + return; + } + v8::Local config = info[0].As(); + + // importMap: accept either a JSON string or an object with `{imports:{}}`. + // The dev server's runtime-config endpoint serializes as an object; + // older entry paths pass a serialized JSON string. Accept both shapes. + v8::Local importMapVal; + if (config->Get(ctx, ToV8String(isolate, "importMap")).ToLocal(&importMapVal) && + !importMapVal->IsUndefined() && !importMapVal->IsNull()) { + std::string jsonStr; + if (importMapVal->IsString()) { + v8::String::Utf8Value utf8(isolate, importMapVal); + if (*utf8) jsonStr = *utf8; + } else if (importMapVal->IsObject()) { + v8::Local jsonGlobalVal; + if (ctx->Global()->Get(ctx, ToV8String(isolate, "JSON")).ToLocal(&jsonGlobalVal) && + jsonGlobalVal->IsObject()) { + v8::Local jsonObj = jsonGlobalVal.As(); + v8::Local stringifyVal; + if (jsonObj->Get(ctx, ToV8String(isolate, "stringify")).ToLocal(&stringifyVal) && + stringifyVal->IsFunction()) { + v8::Local stringify = stringifyVal.As(); + v8::Local args[] = {importMapVal}; + v8::Local result; + if (stringify->Call(ctx, jsonObj, 1, args).ToLocal(&result) && result->IsString()) { + v8::String::Utf8Value utf8(isolate, result); + if (*utf8) jsonStr = *utf8; + } + } + } + } + if (!jsonStr.empty()) { + SetImportMap(jsonStr); + if (logScriptLoading) { + DEBUG_WRITE("[__nsConfigureRuntime] import map set (%zu bytes)", jsonStr.size()); + } + } + } + + // volatilePatterns: list of URL substrings that should always re-fetch. + v8::Local vpVal; + if (config->Get(ctx, ToV8String(isolate, "volatilePatterns")).ToLocal(&vpVal) && vpVal->IsArray()) { + v8::Local arr = vpVal.As(); + std::vector patterns; + patterns.reserve(arr->Length()); + for (uint32_t i = 0; i < arr->Length(); i++) { + v8::Local elem; + if (arr->Get(ctx, i).ToLocal(&elem) && elem->IsString()) { + v8::String::Utf8Value utf8(isolate, elem); + if (*utf8) patterns.emplace_back(*utf8); + } + } + if (!patterns.empty()) { + SetVolatilePatterns(patterns); + if (logScriptLoading) { + DEBUG_WRITE("[__nsConfigureRuntime] %zu volatile patterns set", patterns.size()); + } + } + } +} + +// Helper: wrap Runtime::RunModule (which is `void` on Android) in a try/catch +// so we can report success/failure to the dev-session callbacks by treating +// any NativeScriptException as failure. +// +// `outErrorMessage` captures `ex.what()` from the inner NativeScriptException +// so the caller can pass the real cause through to the JS-side rejection +// instead of losing it behind a generic "failed to import" message. (The +// `[dev-session] RunModule failed for %s: %s` log is gated behind +// `logScriptLoading` so users who haven't opted in still see at least the +// wrapped reason via the rejected `__nsStartDevSession` promise.) +bool RunModuleSafe(Runtime* runtime, const std::string& url, + std::string* outErrorMessage = nullptr) { + try { + runtime->RunModule(url.c_str()); + return true; + } catch (NativeScriptException& ex) { + if (tns::IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dev-session] RunModule failed for %s: %s", + url.c_str(), ex.what()); + } + if (outErrorMessage) { + *outErrorMessage = ex.what() ? ex.what() : ""; + } + return false; } catch (...) { + if (tns::IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dev-session] RunModule unknown exception for %s", url.c_str()); + } + if (outErrorMessage) { + *outErrorMessage = ""; + } return false; } } +void StartDevSessionCallback(const v8::FunctionCallbackInfo& info) { + v8::Isolate* isolate = info.GetIsolate(); + v8::HandleScope scope(isolate); + v8::Local ctx = isolate->GetCurrentContext(); + + // Defense in depth: dev sessions never start in a release build. The install + // site is already debug-gated; reject here too in case that gate is bypassed. + if (!tns::IsDebuggable()) { + info.GetReturnValue().Set(CreateRejectedPromise( + ctx, v8::Exception::Error(ToV8String( + isolate, + "[__nsStartDevSession] dev sessions are disabled in release builds")))); + return; + } + + if (info.Length() < 1 || !info[0]->IsObject()) { + info.GetReturnValue().Set(CreateRejectedPromise( + ctx, v8::Exception::TypeError( + ToV8String(isolate, "[__nsStartDevSession] expected config object")))); + return; + } + + v8::Local config = info[0].As(); + DevSessionState next; + std::string sessionError; + if (!ReadDevSessionConfig(isolate, ctx, config, &next, &sessionError)) { + info.GetReturnValue().Set(CreateRejectedPromise( + ctx, v8::Exception::TypeError(ToV8String(isolate, sessionError.c_str())))); + return; + } + + DevSessionState previous = GetActiveDevSessionSnapshot(); + bool sessionChanged = HasDevSessionChanged(previous, next); + bool logScriptLoading = tns::IsScriptLoadingLogEnabled(); + + if (sessionChanged && previous.active) { + std::vector staleUrls = CollectSessionModuleUrls(previous); + if (logScriptLoading) { + DEBUG_WRITE("[__nsStartDevSession] session changed old=%s new=%s invalidating=%lu", + previous.sessionId.c_str(), next.sessionId.c_str(), + (unsigned long)staleUrls.size()); + } + if (!staleUrls.empty()) { + InvalidateModules(staleUrls); + } + } + + if (!sessionChanged && previous.active && previous.started) { + if (logScriptLoading) { + DEBUG_WRITE("[__nsStartDevSession] session already active: %s", next.sessionId.c_str()); + } + info.GetReturnValue().Set(CreateResolvedPromise(isolate, ctx)); + return; + } + + // Optional native runtime-config delegation. Gated on a global flag the + // JS side may set to opt in. When disabled, the JS dev session is + // expected to call `__nsConfigureRuntime` itself. + bool nativeDelegation = false; + v8::Local delegationFlag; + if (ctx->Global() + ->Get(ctx, ToV8String(isolate, "__NS_EXPERIMENTAL_NATIVE_RUNTIME_CONFIG_URL__")) + .ToLocal(&delegationFlag) && + !delegationFlag->IsUndefined() && !delegationFlag->IsNull()) { + nativeDelegation = delegationFlag->BooleanValue(isolate); + } + if (!next.runtimeConfigUrl.empty() && nativeDelegation) { + if (logScriptLoading) { + DEBUG_WRITE("[__nsStartDevSession] runtimeConfigUrl fetch start session=%s url=%s", + next.sessionId.c_str(), next.runtimeConfigUrl.c_str()); + } + std::string runtimeConfigError; + if (!ApplyDevRuntimeConfigFromUrl(next.runtimeConfigUrl, &runtimeConfigError)) { + if (logScriptLoading) { + DEBUG_WRITE("[__nsStartDevSession] runtimeConfigUrl fetch failed session=%s url=%s", + next.sessionId.c_str(), next.runtimeConfigUrl.c_str()); + } + info.GetReturnValue().Set(CreateRejectedPromise( + ctx, v8::Exception::Error(ToV8String(isolate, runtimeConfigError.c_str())))); + return; + } + if (logScriptLoading) { + DEBUG_WRITE("[__nsStartDevSession] runtimeConfigUrl fetch complete session=%s url=%s", + next.sessionId.c_str(), next.runtimeConfigUrl.c_str()); + } + } else if (!next.runtimeConfigUrl.empty() && logScriptLoading) { + DEBUG_WRITE( + "[__nsStartDevSession] runtimeConfigUrl native delegation disabled; using JS-configured " + "runtime session=%s url=%s", + next.sessionId.c_str(), next.runtimeConfigUrl.c_str()); + } + + ApplyDevSessionGlobals(isolate, ctx, next); + StoreActiveDevSession(next); + + Runtime* runtime = Runtime::GetRuntime(isolate); + if (runtime == nullptr) { + if (logScriptLoading) { + DEBUG_WRITE("[__nsStartDevSession] runtime unavailable for session=%s", + next.sessionId.c_str()); + } + info.GetReturnValue().Set(CreateRejectedPromise( + ctx, v8::Exception::Error(ToV8String(isolate, "[__nsStartDevSession] runtime unavailable")))); + return; + } + + if (logScriptLoading) { + DEBUG_WRITE("[__nsStartDevSession] clientUrl import start session=%s url=%s", + next.sessionId.c_str(), next.clientUrl.c_str()); + } + { + std::string clientErr; + if (!RunModuleSafe(runtime, next.clientUrl, &clientErr)) { + std::string msg = std::string("[__nsStartDevSession] failed to import clientUrl: ") + + next.clientUrl + " — " + (clientErr.empty() ? "" : clientErr); + info.GetReturnValue().Set(CreateRejectedPromise( + ctx, v8::Exception::Error(ToV8String(isolate, msg.c_str())))); + return; + } + } + if (logScriptLoading) { + DEBUG_WRITE("[__nsStartDevSession] clientUrl import complete session=%s url=%s", + next.sessionId.c_str(), next.clientUrl.c_str()); + DEBUG_WRITE("[__nsStartDevSession] entryUrl import start session=%s url=%s", + next.sessionId.c_str(), next.entryUrl.c_str()); + } + { + std::string entryErr; + if (!RunModuleSafe(runtime, next.entryUrl, &entryErr)) { + std::string msg = std::string("[__nsStartDevSession] failed to import entryUrl: ") + + next.entryUrl + " — " + (entryErr.empty() ? "" : entryErr); + info.GetReturnValue().Set(CreateRejectedPromise( + ctx, v8::Exception::Error(ToV8String(isolate, msg.c_str())))); + return; + } + } + + next.started = true; + StoreActiveDevSession(next); + + if (logScriptLoading) { + DEBUG_WRITE("[__nsStartDevSession] entryUrl import complete session=%s url=%s", + next.sessionId.c_str(), next.entryUrl.c_str()); + DEBUG_WRITE("[__nsStartDevSession] session=%s platform=%s origin=%s client=%s entry=%s changed=%s", + next.sessionId.c_str(), next.platform.c_str(), next.origin.c_str(), + next.clientUrl.c_str(), next.entryUrl.c_str(), + sessionChanged ? "true" : "false"); + } + info.GetReturnValue().Set(CreateResolvedPromise(isolate, ctx)); +} + +void InvalidateModulesCallback(const v8::FunctionCallbackInfo& info) { + v8::Isolate* isolate = info.GetIsolate(); + v8::HandleScope scope(isolate); + v8::Local ctx = isolate->GetCurrentContext(); + + if (info.Length() < 1 || !info[0]->IsArray()) { + if (tns::IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[__nsInvalidateModules] expected array of URL strings"); + } + return; + } + v8::Local urlsArray = info[0].As(); + std::vector urls; + urls.reserve(urlsArray->Length()); + for (uint32_t i = 0; i < urlsArray->Length(); i++) { + v8::Local v; + if (!urlsArray->Get(ctx, i).ToLocal(&v) || !v->IsString()) continue; + v8::String::Utf8Value utf8(isolate, v); + if (*utf8) urls.emplace_back(*utf8); + } + if (tns::IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[ns-hmr][android-invalidate] called urls.count=%zu", urls.size()); + size_t shown = 0; + for (const auto& u : urls) { + if (shown >= 32) break; + DEBUG_WRITE("[ns-hmr][android-invalidate] url[%zu]=%s", shown, u.c_str()); + ++shown; + } + if (urls.size() > shown) { + DEBUG_WRITE("[ns-hmr][android-invalidate] (hidden %zu more URL(s))", urls.size() - shown); + } + } + InvalidateModules(urls); +} + +void KickstartHmrPrefetchCallback(const v8::FunctionCallbackInfo& info) { + v8::Isolate* isolate = info.GetIsolate(); + v8::HandleScope scope(isolate); + v8::Local ctx = isolate->GetCurrentContext(); + + auto buildResult = [&](bool ok, size_t fetched, uint64_t elapsedMs) { + v8::Local result = v8::Object::New(isolate); + (void)result->Set(ctx, ToV8String(isolate, "ok"), v8::Boolean::New(isolate, ok)); + (void)result->Set(ctx, ToV8String(isolate, "fetched"), + v8::Integer::NewFromUnsigned(isolate, (uint32_t)fetched)); + (void)result->Set(ctx, ToV8String(isolate, "ms"), + v8::Number::New(isolate, (double)elapsedMs)); + info.GetReturnValue().Set(result); + }; + + if (info.Length() < 1 || (!info[0]->IsString() && !info[0]->IsArray())) { + if (tns::IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[__nsKickstartHmrPrefetch] expected (seedUrl: string, options?) or (urls: string[], options?)"); + } + buildResult(false, 0, 0); + return; + } + + int maxConcurrent = 16; + double timeoutSeconds = 10.0; + if (info.Length() >= 2 && info[1]->IsObject()) { + v8::Local options = info[1].As(); + v8::Local mcVal; + if (options->Get(ctx, ToV8String(isolate, "maxConcurrent")).ToLocal(&mcVal) && + mcVal->IsNumber()) { + double mc = mcVal->NumberValue(ctx).FromMaybe(16.0); + if (mc >= 1.0 && mc <= 64.0) maxConcurrent = (int)mc; + } + v8::Local toVal; + if (options->Get(ctx, ToV8String(isolate, "timeoutMs")).ToLocal(&toVal) && + toVal->IsNumber()) { + double ms = toVal->NumberValue(ctx).FromMaybe(10000.0); + if (ms >= 100.0 && ms <= 60000.0) timeoutSeconds = ms / 1000.0; + } + } + + size_t fetched = 0; + uint64_t elapsedMs = 0; + if (info[0]->IsArray()) { + v8::Local arr = info[0].As(); + const uint32_t len = arr->Length(); + std::vector urls; + urls.reserve(len); + for (uint32_t i = 0; i < len; i++) { + v8::Local elem; + if (!arr->Get(ctx, i).ToLocal(&elem)) continue; + if (!elem->IsString()) continue; + v8::String::Utf8Value u8(isolate, elem); + if (!*u8) continue; + std::string s(*u8); + if (s.empty()) continue; + urls.push_back(std::move(s)); + } + if (urls.empty()) { + buildResult(false, 0, 0); + return; + } + bool ok = KickstartHmrPrefetchUrlsSync(urls, maxConcurrent, timeoutSeconds, + &fetched, &elapsedMs); + buildResult(ok, fetched, elapsedMs); + return; + } + + v8::String::Utf8Value seedUtf8(isolate, info[0]); + if (!*seedUtf8) { + buildResult(false, 0, 0); + return; + } + std::string seedUrl(*seedUtf8); + bool ok = KickstartHmrPrefetchSync(seedUrl, maxConcurrent, timeoutSeconds, + &fetched, &elapsedMs); + buildResult(ok, fetched, elapsedMs); +} + +void ReloadDevAppCallback(const v8::FunctionCallbackInfo& info) { + v8::Isolate* isolate = info.GetIsolate(); + v8::HandleScope scope(isolate); + v8::Local ctx = isolate->GetCurrentContext(); + bool logScriptLoading = tns::IsScriptLoadingLogEnabled(); + + DevSessionState session = GetActiveDevSessionSnapshot(); + if (!session.active || session.entryUrl.empty()) { + if (logScriptLoading) { + DEBUG_WRITE("[__nsReloadDevApp] no active dev session"); + } + info.GetReturnValue().Set(CreateRejectedPromise( + ctx, v8::Exception::Error( + ToV8String(isolate, "[__nsReloadDevApp] no active dev session")))); + return; + } + std::vector sessionUrls = CollectSessionModuleUrls(session); + if (logScriptLoading) { + DEBUG_WRITE("[__nsReloadDevApp] invalidating session=%s urls=%lu", + session.sessionId.c_str(), (unsigned long)sessionUrls.size()); + } + if (!sessionUrls.empty()) { + InvalidateModules(sessionUrls); + } + SetDevSessionBootComplete(isolate, ctx, false); + Runtime* runtime = Runtime::GetRuntime(isolate); + if (runtime == nullptr) { + info.GetReturnValue().Set(CreateRejectedPromise( + ctx, v8::Exception::Error( + ToV8String(isolate, "[__nsReloadDevApp] runtime unavailable")))); + return; + } + if (logScriptLoading) { + DEBUG_WRITE("[__nsReloadDevApp] entryUrl import start session=%s url=%s", + session.sessionId.c_str(), session.entryUrl.c_str()); + } + if (!RunModuleSafe(runtime, session.entryUrl)) { + info.GetReturnValue().Set(CreateRejectedPromise( + ctx, v8::Exception::Error( + ToV8String(isolate, "[__nsReloadDevApp] failed to import entryUrl")))); + return; + } + if (logScriptLoading) { + DEBUG_WRITE("[__nsReloadDevApp] entryUrl import complete session=%s url=%s", + session.sessionId.c_str(), session.entryUrl.c_str()); + DEBUG_WRITE("[__nsReloadDevApp] session=%s reload complete (invalidated=%lu)", + session.sessionId.c_str(), (unsigned long)sessionUrls.size()); + } + info.GetReturnValue().Set(CreateResolvedPromise(isolate, ctx)); +} + +void ApplyStyleUpdateCallback(const v8::FunctionCallbackInfo& info) { + v8::Isolate* isolate = info.GetIsolate(); + v8::HandleScope scope(isolate); + v8::Local ctx = isolate->GetCurrentContext(); + const bool logEnabled = tns::IsScriptLoadingLogEnabled(); + + if (info.Length() < 1 || !info[0]->IsObject()) { + if (logEnabled) DEBUG_WRITE("[__nsApplyStyleUpdate] expected payload object"); + return; + } + v8::Local payload = info[0].As(); + std::string cssText; + std::string url; + GetOptionalStringProperty(isolate, ctx, payload, "cssText", &cssText); + GetOptionalStringProperty(isolate, ctx, payload, "url", &url); + if (cssText.empty()) { + if (logEnabled) DEBUG_WRITE("[__nsApplyStyleUpdate] missing cssText payload"); + return; + } + + v8::Local applicationValue; + if (!ctx->Global()->Get(ctx, ToV8String(isolate, "Application")).ToLocal(&applicationValue) || + !applicationValue->IsObject()) { + if (logEnabled) DEBUG_WRITE("[__nsApplyStyleUpdate] Application is unavailable for %s", url.c_str()); + return; + } + v8::Local applicationObject = applicationValue.As(); + v8::Local addCssValue; + if (!applicationObject->Get(ctx, ToV8String(isolate, "addCss")).ToLocal(&addCssValue) || + !addCssValue->IsFunction()) { + if (logEnabled) DEBUG_WRITE("[__nsApplyStyleUpdate] Application.addCss is unavailable for %s", url.c_str()); + return; + } + v8::TryCatch tc(isolate); + v8::Local args[] = {ToV8String(isolate, cssText.c_str())}; + v8::Local ignored; + bool addCssCalled = + addCssValue.As()->Call(ctx, applicationObject, 1, args).ToLocal(&ignored); + if (addCssCalled && !tc.HasCaught()) { + v8::Local getRootViewValue; + if (applicationObject->Get(ctx, ToV8String(isolate, "getRootView")).ToLocal(&getRootViewValue) && + getRootViewValue->IsFunction()) { + v8::Local rootViewValue; + if (getRootViewValue.As() + ->Call(ctx, applicationObject, 0, nullptr) + .ToLocal(&rootViewValue) && + rootViewValue->IsObject()) { + v8::Local rootViewObject = rootViewValue.As(); + v8::Local cssStateChangeValue; + if (rootViewObject->Get(ctx, ToV8String(isolate, "_onCssStateChange")) + .ToLocal(&cssStateChangeValue) && + cssStateChangeValue->IsFunction()) { + (void)cssStateChangeValue.As() + ->Call(ctx, rootViewObject, 0, nullptr) + .ToLocal(&ignored); + } + } + } + } + if (tc.HasCaught() && logEnabled) { + DEBUG_WRITE("[__nsApplyStyleUpdate] failed for %s", url.c_str()); + } + if (logEnabled) { + DEBUG_WRITE("[__nsApplyStyleUpdate] applied %s", url.c_str()); + } +} + +void GetLoadedModuleUrlsCallback(const v8::FunctionCallbackInfo& info) { + v8::Isolate* isolate = info.GetIsolate(); + v8::HandleScope scope(isolate); + v8::Local ctx = isolate->GetCurrentContext(); + std::vector urls = GetLoadedModuleUrls(); + v8::Local result = v8::Array::New(isolate, static_cast(urls.size())); + for (uint32_t i = 0; i < urls.size(); i++) { + (void)result->Set(ctx, i, ToV8String(isolate, urls[i].c_str())); + } + info.GetReturnValue().Set(result); +} + +void InstallGlobalFunction(v8::Isolate* isolate, v8::Local context, + const char* name, v8::FunctionCallback callback) { + v8::Local fnTpl = v8::FunctionTemplate::New(isolate, callback); + v8::Local fn = fnTpl->GetFunction(context).ToLocalChecked(); + fn->SetName(ToV8String(isolate, name)); + context->Global()->Set(context, ToV8String(isolate, name), fn).FromMaybe(false); + MirrorFunctionOnGlobalThis(isolate, context, name); +} + +} // anonymous namespace + +void InitializeHmrDevGlobals(v8::Isolate* isolate, v8::Local context) { + // Install the per-module HMR helpers and the dev-session global surface. + // The main-thread AND debug-mode gating happens at the SINGLE call site in + // `Runtime::PrepareV8Runtime` (`if (m_isMainThread && isDebuggable)`), so a + // release build never reaches this function. The session-mutating callbacks + // below additionally fail safe via `tns::IsDebuggable()` as defense in depth + // in case a future caller forgets the call-site gate. + try { + InitializeHotEventDispatcher(isolate, context); + InitializeHotDisposeRunner(isolate, context); + InitializeHotPruneRunner(isolate, context); + InitializeHotDeclinedHelper(isolate, context); + } catch (...) { + // Don't crash if HMR setup fails — the rest of init must still run. + } + + // Install the dev-session bootstrap surface. + InstallGlobalFunction(isolate, context, "__nsConfigureDevRuntime", ConfigureDevRuntimeCallback); + InstallGlobalFunction(isolate, context, "__nsConfigureRuntime", ConfigureDevRuntimeCallback); + (void)context->Global() + ->CreateDataProperty(context, ToV8String(isolate, "__nsSupportsRuntimeConfigUrl"), + v8::Boolean::New(isolate, true)) + .FromMaybe(false); + + InstallGlobalFunction(isolate, context, "__nsStartDevSession", StartDevSessionCallback); + InstallGlobalFunction(isolate, context, "__nsInvalidateModules", InvalidateModulesCallback); + InstallGlobalFunction(isolate, context, "__nsKickstartHmrPrefetch", KickstartHmrPrefetchCallback); + InstallGlobalFunction(isolate, context, "__nsReloadDevApp", ReloadDevAppCallback); + InstallGlobalFunction(isolate, context, "__nsApplyStyleUpdate", ApplyStyleUpdateCallback); + InstallGlobalFunction(isolate, context, "__nsGetLoadedModuleUrls", GetLoadedModuleUrlsCallback); +} + +void CleanupHMRGlobals() { + // Reset all v8::Global handles BEFORE the isolate is disposed. + // These static maps survive past isolate teardown and their destructors + // (__cxa_finalize_ranges) would call v8::Global::Reset() on an already- + // destroyed isolate, causing a crash in v8::internal::GlobalHandles::Destroy(). + for (auto& kv : g_hotData) { kv.second.Reset(); } + g_hotData.clear(); + + for (auto& kv : g_hotAccept) { + for (auto& fn : kv.second) { fn.Reset(); } + } + g_hotAccept.clear(); + + for (auto& kv : g_hotDispose) { + for (auto& fn : kv.second) { fn.Reset(); } + } + g_hotDispose.clear(); + + for (auto& kv : g_hotPrune) { + for (auto& fn : kv.second) { fn.Reset(); } + } + g_hotPrune.clear(); + + for (auto& kv : g_hotEventListeners) { + for (auto& fn : kv.second) { fn.Reset(); } + } + g_hotEventListeners.clear(); + + { + // `g_hotDeclined` holds plain strings — no v8::Global handles — but + // we still clear it under its own mutex on teardown so a re-launched + // runtime in the same process starts with a clean slate. + std::lock_guard lock(g_hotDeclinedMutex); + g_hotDeclined.clear(); + } + + // Drop any speculatively-prefetched module sources. These are plain + // std::string buffers (no v8::Global), but flushing them on teardown + // prevents stale source from leaking into a re-launched runtime in + // the same process. + ClearHttpModulePrefetchCache(); +} + } // namespace tns diff --git a/test-app/runtime/src/main/cpp/HMRSupport.h b/test-app/runtime/src/main/cpp/HMRSupport.h index f08e7fa09..e3ed44667 100644 --- a/test-app/runtime/src/main/cpp/HMRSupport.h +++ b/test-app/runtime/src/main/cpp/HMRSupport.h @@ -3,23 +3,380 @@ #include #include -#include + +// Forward declare v8 types to keep this header lightweight and avoid +// requiring V8 headers at include sites. +namespace v8 { +class Isolate; +template class Local; +class Object; +class Function; +class Context; +class Value; +class Promise; +} namespace tns { -// import.meta.hot support +// HMRSupport: Isolated helpers for minimal HMR (import.meta.hot) support. +// +// This module contains: +// - Per-module hot data store +// - Registration for accept/disable callbacks +// - Active dev-session state and helpers +// - Initializer to attach import.meta.hot to a module's import.meta +// +// Note: Triggering/dispatch is handled by the HMR system elsewhere. + +// Retrieve or create the per-module hot data object. v8::Local GetOrCreateHotData(v8::Isolate* isolate, const std::string& key); + +// Register accept and dispose callbacks for a module key. void RegisterHotAccept(v8::Isolate* isolate, const std::string& key, v8::Local cb); void RegisterHotDispose(v8::Isolate* isolate, const std::string& key, v8::Local cb); + +// Register prune callbacks for a module key. Per Vite spec these fire when the +// module is removed from the dependency graph (NOT on every update — that is +// dispose). The registry is plumbed end-to-end; a per-module HMR client drains +// it via `__nsRunHmrPrune`. +void RegisterHotPrune(v8::Isolate* isolate, const std::string& key, v8::Local cb); + +// Optional: expose read helpers (may be useful for debugging/integration) std::vector> GetHotAcceptCallbacks(v8::Isolate* isolate, const std::string& key); std::vector> GetHotDisposeCallbacks(v8::Isolate* isolate, const std::string& key); +std::vector> GetHotPruneCallbacks(v8::Isolate* isolate, const std::string& key); + +// `import.meta.hot` implementation — Vite-spec compliant API surface. +// +// Per-module API exposed on every imported module: +// - `hot.data` — per-module persistent object across HMR updates +// - `hot.accept(deps?, cb?)` — register a self-accepting handler (deps arg accepted but currently ignored) +// - `hot.dispose(cb)` — register a cleanup callback fired when this module is replaced +// - `hot.prune(cb)` — register a callback fired when this module is removed from the dep graph +// - `hot.decline()` — opt this module out of HMR (next update touching it triggers full reload) +// - `hot.invalidate(msg?)` — request a full app reload from this module (delegates to `__nsReloadDevApp`) +// - `hot.on(event, cb)` — listen to HMR events (Vite standard `vite:beforeUpdate` / `vite:afterUpdate` / +// `vite:beforeFullReload` / `vite:beforePrune` / `vite:invalidate` / `vite:error`, +// plus custom events the HMR client dispatches via `__NS_DISPATCH_HOT_EVENT__`) +// - `hot.off(event, cb)` — unregister a listener previously added with `hot.on` +// - `hot.send(event, data)` — send a custom message to the dev server; delegated to a JS-installed +// `globalThis.__nsHmrSendToServer(event, data)` so the WebSocket-owning JS layer +// keeps sole responsibility for the transport (runtime stays transport-agnostic) +// +// `modulePath` is used to derive the per-module canonical key for `hot.data` and callback registries. void InitializeImportMetaHot(v8::Isolate* isolate, v8::Local context, v8::Local importMeta, const std::string& modulePath); -// Dev HTTP loader helpers +// ───────────────────────────────────────────────────────────── +// Dev session helpers + +struct DevSessionState { + bool active = false; + bool started = false; + std::string sessionId; + std::string origin; + std::string entryUrl; + std::string clientUrl; + std::string wsUrl; + std::string platform; + std::string runtimeConfigUrl; + bool fullReload = false; + bool cssHmr = false; +}; + +// Read and validate the JS dev-session config object. +bool ReadDevSessionConfig(v8::Isolate* isolate, + v8::Local context, + v8::Local config, + DevSessionState* out, + std::string* errorMessage); + +// Active dev-session storage. +void ResetActiveDevSession(); +DevSessionState GetActiveDevSessionSnapshot(); +void StoreActiveDevSession(const DevSessionState& session); +bool HasDevSessionChanged(const DevSessionState& previous, + const DevSessionState& next); +std::vector CollectSessionModuleUrls(const DevSessionState& session); +bool ApplyDevRuntimeConfigFromUrl(const std::string& url, + std::string* errorMessage); + +// Runtime global helpers for the deterministic dev session boot path. +void ApplyDevSessionGlobals(v8::Isolate* isolate, + v8::Local context, + const DevSessionState& session); +void SetDevSessionBootComplete(v8::Isolate* isolate, + v8::Local context, + bool value); + +// ───────────────────────────────────────────────────────────── +// HTTP loader helpers (used by dev/HMR and general-purpose HTTP module loading) +// +// Normalize an HTTP(S) URL into a stable module registry/cache key. +// - Always strips URL fragments. +// - For NativeScript dev endpoints, normalizes known cache busters (e.g. t/v/import) +// and normalizes some versioned bridge paths. +// - For non-dev/public URLs, preserves the full query string as part of the cache key. std::string CanonicalizeHttpUrlKey(const std::string& url); + +// Resolve a relative/root-absolute import specifier against a parent URL +// using plain string manipulation. Only relative (`./`, `../`) and +// root-absolute (`/`) specifiers are resolved here; bare specifiers +// return the empty string, and already-absolute http(s) URLs are +// returned unchanged. Returns the empty string when `parentUrl` is not +// http(s). +std::string ResolveImportSpecifierAgainstUrl(const std::string& specifier, + const std::string& parentUrl); + +// Minimal text fetch for HTTP ESM loader. Returns true on 2xx with non-empty body. +// - out: response body +// - contentType: Content-Type header if present +// - status: HTTP status code +// +// On a fast path, returns from the in-memory speculative-prefetch cache +// without touching the network. On the slow path, performs a synchronous +// fetch and additionally schedules background prefetches for the body's +// static imports so subsequent HttpFetchText calls hit the cache. See +// the prefetcher block in HMRSupport.cpp for full design notes. bool HttpFetchText(const std::string& url, std::string& out, std::string& contentType, int& status); +// Return the most recent low-level fetch error reason for the calling +// thread, or an empty string if the last fetch succeeded (or no fetch +// has run on this thread yet). +// +// Format is grep-friendly and intended for splicing into the JS-side +// error message that `ModuleInternalCallbacks` throws when +// `HttpFetchText` returns `status=0`: +// +// stage=get-response-code class=java.net.ConnectException msg=... +// +// The slot is thread-local because each isolate has its own JS thread; +// concurrent fetches on different threads cannot clobber each other. +// "Take" semantics — the slot is cleared on read so a stale reason +// can never leak into a later, successful fetch. +std::string TakeLastHttpFetchErrorReason(); + +// Drop all entries in the speculative-prefetch cache. Safe to call from +// any thread. Used by Runtime teardown and by HMR cache-poison scenarios +// where the dev server has indicated a graph version bump. +void ClearHttpModulePrefetchCache(); + +// Register a "yield" callback that `HttpFetchText` should invoke around its +// synchronous network turn so the caller can pump its own runloop (e.g. the +// JS-thread runloop so a placeholder UI can repaint during cold-boot). +// +// Default: a built-in pump that no-ops outside the JS thread / after the +// dev-session boot completes (see `MaybePumpJSThreadDuringBoot` in +// HMRSupport.cpp). +// +// Pass `nullptr` to disable any yielding (used by hosts that drive their own +// run loop or by tests that want bit-for-bit deterministic fetch timing). +// Safe to call from any thread; reads use acquire/release ordering. +void RegisterHttpFetchYield(void (*callback)()); + +// Drop a specific URL set from the speculative-prefetch cache. Safe +// to call from any thread; missing keys are silently ignored. Used by +// `InvalidateModules` so that an HMR eviction also purges any stale +// HTTP body the previous prefetch wave (or kickstart) left behind. +// Without this, the kickstart's "skip if URL already cached" +// early-out, plus `HttpFetchText`'s destructive-read fast path, would +// happily serve V8 a stale body from the prior save — visible to the +// user as a 1-cycle lag between save and visual update. +void EvictHttpModulePrefetchCacheUrls(const std::vector& urls); + +// Kickstart an HMR-driven module prefetch +// rooted at `seedUrl`. Walks the static-import graph in parallel (up to +// `maxConcurrent` simultaneous HTTP fetches), storing every reachable +// module body in the speculative-prefetch cache. Blocks the calling +// thread until the BFS has fully drained or `timeoutSeconds` elapses. +// +// Designed to be invoked from JS (via `__nsKickstartHmrPrefetch`) +// immediately before the Angular HMR client re-imports the entry — +// by the time V8 walks the dep tree, every reachable body is already +// in `g_prefetchCache` and the walk runs at memory speed instead of +// network speed (turning a ~3s 200-fetch refresh into ~250ms). +// +// Returns `true` when the BFS drained cleanly. On timeout or seed +// fetch failure returns `false`; callers should treat that as "no +// kickstart speedup this round" and fall back to V8's normal +// synchronous walk, which always succeeds independently. +// +// `outFetchedCount` (optional) receives the number of distinct URLs +// fetched. `outElapsedMs` (optional) receives wall-clock time. +bool KickstartHmrPrefetchSync(const std::string& seedUrl, + int maxConcurrent, + double timeoutSeconds, + size_t* outFetchedCount, + uint64_t* outElapsedMs); + +// Multi-URL kickstart for HMR cycles. Unlike the legacy seed-rooted +// variant above, this one fetches ONLY the explicit URL list it was +// given (no body scanning, no BFS recursion). +// +// This is the right shape for HMR: the dev server's +// `collectAngularEvictionUrls` already computed the inverse-dep +// closure of the changed file; re-discovering it via in-process +// scanning would just duplicate that work and re-fetch modules V8 +// has already compiled. By feeding the precomputed list directly we +// turn N sequential `LoadHttpModuleForUrl` calls (the importer chain +// during V8's ResolveModuleCallback walk) into a single parallel +// wave that completes before V8 starts walking. +// +// Same semantics as `KickstartHmrPrefetchSync` for everything else: +// blocks the calling thread until the wave drains or `timeoutSeconds` +// elapses; cleared/blocked URLs are filtered up front; partial +// success is reported as success (the V8 walk falls back to +// per-module HttpFetchText for anything we couldn't pre-fill). +bool KickstartHmrPrefetchUrlsSync(const std::vector& urls, + int maxConcurrent, + double timeoutSeconds, + size_t* outFetchedCount, + uint64_t* outElapsedMs); + +// Clear all HMR-related v8::Global handles (g_hotData, g_hotAccept, g_hotDispose). +// MUST be called inside Runtime::~Runtime() before isolate disposal to prevent +// crashes during static destructor cleanup (__cxa_finalize_ranges). +void CleanupHMRGlobals(); +// ───────────────────────────────────────────────────────────── +// Custom HMR event support + +// Register a custom event listener (called by import.meta.hot.on()) +void RegisterHotEventListener(v8::Isolate* isolate, const std::string& event, v8::Local cb); + +// Unregister a listener previously added with `RegisterHotEventListener`. The +// callback is matched by V8 strict equality (same `Function` reference). If +// `cb` matches multiple registered listeners (the same closure was registered +// twice), every match is removed — mirrors `EventTarget.removeEventListener` +// semantics for repeated registrations. +void RemoveHotEventListener(v8::Isolate* isolate, const std::string& event, v8::Local cb); + +// Get all listeners for a custom event +std::vector> GetHotEventListeners(v8::Isolate* isolate, const std::string& event); + +// Dispatch a custom event to all registered listeners +// This should be called when the HMR WebSocket receives framework-specific events +void DispatchHotEvent(v8::Isolate* isolate, v8::Local context, const std::string& event, v8::Local data); + +// Initialize the global event dispatcher function (__NS_DISPATCH_HOT_EVENT__) +// This exposes a JavaScript-callable function that the HMR client can use to dispatch events +void InitializeHotEventDispatcher(v8::Isolate* isolate, v8::Local context); + +// Drain and execute `import.meta.hot.dispose(cb)` callbacks for the given module +// keys. If `keys` is empty, drains every registered callback across every module +// (the right behaviour for whole-app HMR reboots like Angular's +// `__reboot_ng_modules__`, where the entire JS realm's side effects are being +// thrown away). Each callback is invoked with that module's `hot.data` object so +// users can persist state across the reload (matches Vite spec). +// +// Callbacks are removed from the registry after execution so a second drain in +// the same cycle is a clean no-op. Per-callback failures are logged (when +// script-loading logs are enabled) but never propagate — one bad disposer must +// not break the HMR cycle for everyone else. +// +// Returns the number of callbacks successfully executed. +int RunHotDisposeCallbacks(v8::Isolate* isolate, v8::Local context, + const std::vector& keys); + +// Initialize the global `__nsRunHmrDispose([keys?])` function so the HMR client +// (e.g. @nativescript/vite's Angular HMR client) can drain dispose callbacks +// from JS. Mirrors the `InitializeHotEventDispatcher` pattern. Should be called +// once per main isolate during runtime init, gated on dev mode. +// +// JS signature: `__nsRunHmrDispose(keys?: string[]) => number` +// - `keys` omitted / null / undefined / empty array → drain everything. +// - `keys` non-empty → drain only the listed module keys. +// - Returns: count of callbacks executed. +void InitializeHotDisposeRunner(v8::Isolate* isolate, v8::Local context); + +// Drain `import.meta.hot.prune(cb)` callbacks for the given module keys (or +// every registered module if `keys` is empty). Same snapshot/swap semantics as +// `RunHotDisposeCallbacks` — callbacks fire exactly once per drain, the +// registry is cleared atomically per key, and per-callback failures are logged +// but never propagate. +// +// Returns the number of callbacks successfully executed. +int RunHotPruneCallbacks(v8::Isolate* isolate, v8::Local context, + const std::vector& keys); + +// Initialize the global `__nsRunHmrPrune([keys?])` function. Symmetric with +// `__nsRunHmrDispose` but for `prune` callbacks. +// +// JS signature: `__nsRunHmrPrune(keys?: string[]) => number` +void InitializeHotPruneRunner(v8::Isolate* isolate, v8::Local context); + +// `decline()` support. When user code calls `import.meta.hot.decline()`, the +// module's canonical key is added to a process-wide declined set. The HMR +// client checks `IsAnyModuleDeclined(updatedKeys)` before applying an update — +// if any updated key is declined, the update is converted into a full reload +// (matches Vite spec: "If the module triggers HMR, full reload occurs"). +void MarkHotDeclined(const std::string& key); + +// Returns true if the given key is in the declined set. Used by the +// `__nsHasDeclinedModule` JS helper below. +bool IsHotDeclined(const std::string& key); + +// Returns true if ANY of the supplied keys are in the declined set, OR if +// the declined set is non-empty AND `keys` is empty (caller is asking +// "is anything declined at all?"). The runtime canonicalizes its registry +// keys via `canonicalHotKey` (strips fragments, normalizes script extensions, +// rewrites NS HMR virtual prefixes); the HMR client should pass canonical +// URLs straight from `evictPaths` for accurate matching. +bool IsAnyModuleDeclined(const std::vector& keys); + +// Initialize the global `__nsHasDeclinedModule([keys?])` function. Returns +// `true` if any of the listed keys is declined (or if the declined set is +// non-empty AND no keys were passed). The Angular HMR client calls this with +// `evictPaths` before reboot; on `true` it falls back to `__nsReloadDevApp()`. +// +// JS signature: `__nsHasDeclinedModule(keys?: string[]) => boolean` +void InitializeHotDeclinedHelper(v8::Isolate* isolate, v8::Local context); + +// ───────────────────────────────────────────────────────────── +// Small v8 utility helpers (shared between Runtime.cpp and HMRSupport.cpp). +// Declared here once so both translation units share a single definition. + +// Read an optional string property from `object` into `*out`. Returns false +// if the property is missing, null, undefined, or non-convertible. +bool GetOptionalStringProperty(v8::Isolate* isolate, v8::Local context, + v8::Local object, const char* key, + std::string* out); + +// Construct an already-resolved Promise. +v8::Local CreateResolvedPromise(v8::Isolate* isolate, + v8::Local context); + +// Construct an already-rejected Promise with the given reason. +v8::Local CreateRejectedPromise(v8::Local context, + v8::Local reason); + +// Mirror a globally-installed function onto `globalThis.` so legacy +// `globalThis.__nsXxx(...)` callers keep working when the runtime installs +// the canonical function on the realm's global object via FunctionTemplate. +void MirrorFunctionOnGlobalThis(v8::Isolate* isolate, v8::Local context, + const char* name); + +// ───────────────────────────────────────────────────────────── +// HMR + dev-session global installer +// +// Installs every JS-callable global the @nativescript/vite HMR client and the +// dev-session bootstrap depend on. Idempotent per realm; safe to call from any +// place that has a fresh context + isolate scope. +// +// JS globals installed (all on the realm's global object AND mirrored on +// globalThis): +// - __nsConfigureDevRuntime / __nsConfigureRuntime (import map + volatile patterns) +// - __nsSupportsRuntimeConfigUrl (data property, true) +// - __nsStartDevSession (async session bootstrap) +// - __nsInvalidateModules (registry eviction) +// - __nsKickstartHmrPrefetch (parallel HTTP prewarm) +// - __nsReloadDevApp (re-import session entry) +// - __nsApplyStyleUpdate (CSS HMR apply) +// - __nsGetLoadedModuleUrls (registry introspection) +// - (debug only) __NS_DISPATCH_HOT_EVENT__, +// __nsRunHmrDispose, __nsRunHmrPrune, +// __nsHasDeclinedModule +void InitializeHmrDevGlobals(v8::Isolate* isolate, v8::Local context); + } // namespace tns diff --git a/test-app/runtime/src/main/cpp/MetadataNode.cpp b/test-app/runtime/src/main/cpp/MetadataNode.cpp index 7d3335fb2..cb1213883 100644 --- a/test-app/runtime/src/main/cpp/MetadataNode.cpp +++ b/test-app/runtime/src/main/cpp/MetadataNode.cpp @@ -1741,8 +1741,6 @@ bool MetadataNode::GetExtendLocation(v8::Isolate* isolate, string& extendLocatio } string srcFileName = ArgConverter::ConvertToString(scriptName); - // trim 'file://' to normalize path to always begin with "/data/" - srcFileName = Util::ReplaceAll(srcFileName, "file://", ""); string fullPathToFile; if (srcFileName == "") { @@ -1754,11 +1752,61 @@ bool MetadataNode::GetExtendLocation(v8::Isolate* isolate, string& extendLocatio // preceding the underscore (_) fullPathToFile = "script"; } else { - string hardcodedPathToSkip = Constants::APP_ROOT_FOLDER_PATH; + // ── Normalize srcFileName down to a path-like string ────────── + // The earlier logic assumed srcFileName was always + // `file:///.js` and stripped the + // scheme + app root before chopping a literal `.js`. HTTP ESM + // loading (HMR dev workflow) passes a full URL like + // `http://127.0.0.1:5173/ns/core/...` with no `.js` suffix and + // no app-root prefix, so that arithmetic could yield an empty + // `fullPathToFile` and crash downstream on an empty token list. + // + // The logic below is shape-aware: + // 1. Strip a leading URL scheme + authority (`file://`, + // `http://host:port`, `https://host:port`). + // 2. Strip a leading `APP_ROOT_FOLDER_PATH` if present. + // 3. Strip the trailing `.js` / `.mjs` extension if present + // (HMR URLs typically omit it). + // 4. Tokenize on `/`, `.`, `-`, ` ` and take the last + // non-empty token, falling back to `"script"` so the + // metadata generator always has a stable class name. + string normalized = srcFileName; + + auto stripPrefix = [](string& s, const string& prefix) { + if (s.size() >= prefix.size() && + s.compare(0, prefix.size(), prefix) == 0) { + s.erase(0, prefix.size()); + } + }; + + stripPrefix(normalized, "file://"); + if (normalized.rfind("http://", 0) == 0 || + normalized.rfind("https://", 0) == 0) { + size_t schemeEnd = normalized.find("://"); + size_t pathStart = normalized.find('/', schemeEnd + 3); + if (pathStart == string::npos) { + normalized.clear(); + } else { + normalized.erase(0, pathStart + 1); + } + } - int startIndex = hardcodedPathToSkip.length(); - int strToTakeLen = (srcFileName.length() - startIndex - 3); // 3 refers to .js at the end of file name - fullPathToFile = srcFileName.substr(startIndex, strToTakeLen); + const string& appRoot = Constants::APP_ROOT_FOLDER_PATH; + if (!appRoot.empty()) { + stripPrefix(normalized, appRoot); + } + + auto endsWith = [](const string& s, const string& suffix) { + return s.size() >= suffix.size() && + s.compare(s.size() - suffix.size(), suffix.size(), suffix) == 0; + }; + if (endsWith(normalized, ".mjs")) { + normalized.resize(normalized.size() - 4); + } else if (endsWith(normalized, ".js")) { + normalized.resize(normalized.size() - 3); + } + + fullPathToFile = normalized; std::replace(fullPathToFile.begin(), fullPathToFile.end(), '/', '_'); std::replace(fullPathToFile.begin(), fullPathToFile.end(), '.', '_'); @@ -1766,10 +1814,21 @@ bool MetadataNode::GetExtendLocation(v8::Isolate* isolate, string& extendLocatio std::replace(fullPathToFile.begin(), fullPathToFile.end(), ' ', '_'); std::vector pathParts; - Util::SplitString(fullPathToFile, "_", pathParts); - std::string lastPathPart = pathParts.back(); + // Pre-fix this was an unconditional `pathParts.back()` and + // SEGV'd when `fullPathToFile` was empty. Walk backwards + // for the last non-empty token; if none, use a sentinel. + std::string lastPathPart; + for (auto it = pathParts.rbegin(); it != pathParts.rend(); ++it) { + if (!it->empty()) { + lastPathPart = *it; + break; + } + } + if (lastPathPart.empty()) { + lastPathPart = "script"; + } fullPathToFile = lastPathPart; } diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.cpp b/test-app/runtime/src/main/cpp/ModuleInternal.cpp index bd41f8c2a..dc2172506 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternal.cpp @@ -19,6 +19,7 @@ #include "CallbackHandlers.h" #include "ManualInstrumentation.h" #include "Runtime.h" +#include "DevFlags.h" #include #include #include @@ -26,13 +27,15 @@ #include #include #include +#include using namespace v8; using namespace std; using namespace tns; -// Global module registry for ES modules: maps absolute file paths → compiled Module handles -std::unordered_map> g_moduleRegistry; +// Per-isolate ES module registry definition lives in ModuleInternalCallbacks.cpp +// (thread_local + leaky-singleton accessor). Declared via ModuleInternalCallbacks.h +// so every call site below shares the same per-thread map. // Helper function to check if a module name looks like an optional external module bool ModuleInternal::IsLikelyOptionalModule(const std::string& moduleName) { @@ -226,10 +229,121 @@ void ModuleInternal::RequireNativeCallback(const v8::FunctionCallbackInfo context, const string& path) { TNSPERF(); auto isolate = m_isolate; + + // HTTP(S) URL fast path. Android's `require()` only resolves file-system + // paths (it delegates to Java's `Module.resolvePath`), so + // `globalThis.require('http://...')` would fail the Java resolve and leave a + // pending V8 exception that the discarded `require->Call(...)` result hides, + // making the dev session report success without evaluating anything. Detect + // HTTP / ES-module specifiers here and route them through + // `tns::LoadHttpModuleForUrl` (fetch + compile + register), then instantiate + // and evaluate against the same `ResolveModuleCallback` the rest of the ESM + // loader uses, so static imports share the registry the dynamic-import + // callback populates. + if (path.rfind("http://", 0) == 0 || path.rfind("https://", 0) == 0) { + bool logEnabled = tns::IsScriptLoadingLogEnabled(); + if (logEnabled) { + DEBUG_WRITE("[run-module][http-esm][begin] %s", path.c_str()); + } + + std::string errMsg; + auto maybeMod = tns::LoadHttpModuleForUrl(isolate, context, path, &errMsg); + Local module; + if (!maybeMod.ToLocal(&module)) { + if (logEnabled) { + DEBUG_WRITE("[run-module][http-esm][load-fail] %s reason=%s", + path.c_str(), errMsg.c_str()); + } + throw NativeScriptException(string("Cannot load HTTP module ") + path + + (errMsg.empty() ? "" : (": " + errMsg))); + } + + if (module->GetStatus() == Module::kUninstantiated) { + TryCatch tcLink(isolate); + bool linked = module->InstantiateModule(context, &ResolveModuleCallback).FromMaybe(false); + if (!linked) { + if (logEnabled) { + DEBUG_WRITE("[run-module][http-esm][instantiate-fail] %s", path.c_str()); + } + if (tcLink.HasCaught()) { + throw NativeScriptException(tcLink, "Cannot instantiate HTTP module " + path); + } + throw NativeScriptException(string("Cannot instantiate HTTP module ") + path); + } + } + + if (module->GetStatus() != Module::kEvaluated) { + TryCatch tcEval(isolate); + Local evalResult; + if (!module->Evaluate(context).ToLocal(&evalResult)) { + if (logEnabled) { + DEBUG_WRITE("[run-module][http-esm][evaluate-fail] %s", path.c_str()); + } + if (tcEval.HasCaught()) { + throw NativeScriptException(tcEval, "Cannot evaluate HTTP module " + path); + } + throw NativeScriptException(string("Cannot evaluate HTTP module ") + path); + } + + // Drain top-level-await the same way `LoadESModule` does so a + // module returning a pending Promise is fully settled before we + // return — otherwise the caller would advance while evaluation is + // still in flight. + if (!evalResult.IsEmpty() && evalResult->IsPromise()) { + Local promise = evalResult.As(); + const int maxAttempts = 100; + int attempts = 0; + while (attempts < maxAttempts) { + isolate->PerformMicrotaskCheckpoint(); + Promise::PromiseState state = promise->State(); + if (state != Promise::kPending) { + if (state == Promise::kRejected) { + Local reason = promise->Result(); + std::string reasonStr; + if (!reason.IsEmpty()) { + v8::Local reasonV8; + if (reason->ToString(context).ToLocal(&reasonV8)) { + reasonStr = ArgConverter::ConvertToString(reasonV8); + } + } + DEBUG_WRITE("[run-module][http-esm][evaluate-rejected] %s reason=%s", + path.c_str(), + reasonStr.empty() ? "" : reasonStr.c_str()); + isolate->ThrowException(reason); + throw NativeScriptException( + string("HTTP module evaluation promise rejected: ") + path + + (reasonStr.empty() ? "" : (" — " + reasonStr))); + } + break; + } + attempts++; + usleep(100); + } + } + } + + if (logEnabled) { + DEBUG_WRITE("[run-module][http-esm][ok] %s", path.c_str()); + } + return; + } + auto globalObject = context->Global(); auto require = globalObject->Get(context, ArgConverter::ConvertToV8String(isolate, "require")).ToLocalChecked().As(); Local args[] = { ArgConverter::ConvertToV8String(isolate, path) }; - require->Call(context, globalObject, 1, args); + + // Surface JS exceptions thrown by `require` instead of leaving them as + // pending V8 exceptions: a discarded `require->Call(...)` result lets the + // C++ caller see success while the JS side still carries the exception, + // which the next entry-point then mis-attributes. + TryCatch tc(isolate); + Local callResult; + if (!require->Call(context, globalObject, 1, args).ToLocal(&callResult)) { + if (tc.HasCaught()) { + throw NativeScriptException(tc, "require() failed for module " + path); + } + throw NativeScriptException(string("require() failed for module ") + path); + } } void ModuleInternal::LoadWorker(Local context, const string& path) { @@ -602,8 +716,23 @@ Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& p if (state != Promise::kPending) { if (state == Promise::kRejected) { Local reason = promise->Result(); + // Best-effort extract a human-readable reason so the + // wrapped C++ exception names the actual cause instead + // of just "Module evaluation promise rejected: ". + std::string reasonStr; + if (!reason.IsEmpty()) { + v8::Local reasonV8; + if (reason->ToString(context).ToLocal(&reasonV8)) { + reasonStr = ArgConverter::ConvertToString(reasonV8); + } + } + DEBUG_WRITE("[esm][evaluate-rejected] %s reason=%s", + path.c_str(), + reasonStr.empty() ? "" : reasonStr.c_str()); isolate->ThrowException(reason); - throw NativeScriptException(string("Module evaluation promise rejected: ") + path); + throw NativeScriptException( + string("Module evaluation promise rejected: ") + path + + (reasonStr.empty() ? "" : (" — " + reasonStr))); } break; } diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp index 6beb7124f..448080352 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp @@ -1,4 +1,5 @@ #include "ModuleInternal.h" +#include "ModuleInternalCallbacks.h" #include "ArgConverter.h" #include "NativeScriptException.h" #include "NativeScriptAssert.h" @@ -9,7 +10,11 @@ #include #include #include +#include +#include #include +#include +#include #include "HMRSupport.h" #include "DevFlags.h" #include "JEnv.h" @@ -18,8 +23,107 @@ using namespace v8; using namespace std; using namespace tns; -// External global module registry declared in ModuleInternal.cpp -extern std::unordered_map> g_moduleRegistry; +// ──────────────────────────────────────────────────────────────────────────── +// Forward declarations for helpers defined below their first use. Every helper +// called from ResolveModuleCallback / ImportModuleDynamicallyCallback is +// declared here so definition order within the translation unit doesn't matter. +namespace tns { +static inline bool StartsWith(const std::string& s, const char* prefix); +static inline bool EndsWith(const std::string& s, const char* suffix); +static std::string CanonicalizeRegistryKey(const std::string& key); +static std::string NormalizeViteSpecifier(const std::string& specifier); +static std::string LookupImportMap(const std::string& specifier); +static bool HasUrlScheme(const std::string& spec); +static bool IsSyntheticNamespaceKey(const std::string& key); +static bool IsVolatileUrl(const std::string& url); +static v8::MaybeLocal CompileModuleForResolveRegisterOnly( + v8::Isolate* isolate, v8::Local context, + const std::string& source, const std::string& registryKey); +static v8::MaybeLocal ResolveFromVendorRegistry( + v8::Isolate* isolate, v8::Local context, + const std::string& vendorId); +} // namespace tns + +// ──────────────────────────────────────────────────────────────────────────── +// Per-isolate ES module registry. +// +// `g_moduleRegistry` is `thread_local`: each NS isolate (the main JS thread +// plus each Worker thread) gets its own per-thread map. `v8::Global` +// handles are isolate-bound; sharing one map across isolates lets thread A +// fetch a handle thread B created, and V8 will fail the identity check during +// `InstantiateModule` (the dependency module belongs to a different isolate +// than the module it's being linked into). We use the reference + leaky +// singleton pattern so that every call site continues to write +// `g_moduleRegistry[key] = …` without churning ~100+ call sites to use +// accessor functions, AND so that we don't run a destructor on the underlying +// map when the worker thread tears down (which would call +// `v8::Global::Reset()` on handles whose isolate may already be gone). +// +// On each thread's first use of `g_moduleRegistry`, the initializer below +// runs once per thread to bind the reference to that thread's map. +namespace { +using ModuleHandleMap = std::unordered_map>; + +ModuleHandleMap& MakePerIsolateModuleRegistry() { + thread_local auto* p = new ModuleHandleMap(); + return *p; +} +} // namespace + +namespace tns { +thread_local std::unordered_map>& g_moduleRegistry = + MakePerIsolateModuleRegistry(); +} // namespace tns + +// ──────────────────────────────────────────────────────────────────────────── +// Per-process module-resolution state (import map + vendor cache + fallbacks). +// +// These are leaky singletons for the same reason as the HMR registries in +// HMRSupport.cpp: their destructors would call `v8::Global::Reset()` +// during `__cxa_finalize_ranges`, after the owning isolate is already gone, +// crashing the process on app exit. Heap-allocate + leak (`new` once, never +// `delete`) so the OS reclaims memory on process exit and we never run a +// destructor that touches V8. +// +// Vendor cache is `thread_local` because vendor SyntheticModules are +// isolate-bound. Reusing one across isolates breaks the linker's +// export-table check. The other registries are process-wide configuration +// (no v8::Global) and intentionally shared across isolates. + +namespace tns { + +static std::mutex g_importMapMutex; +static auto* _g_importMap = new std::unordered_map(); +static auto& g_importMap = *_g_importMap; + +static std::mutex g_volatilePatternsMutex; +static auto* _g_volatilePatterns = new std::vector(); +static auto& g_volatilePatterns = *_g_volatilePatterns; + +namespace { +using ModuleHandleMap = std::unordered_map>; + +ModuleHandleMap& MakePerIsolateVendorModuleCache() { + thread_local auto* p = new ModuleHandleMap(); + return *p; +} +} // namespace + +static thread_local std::unordered_map>& g_vendorModuleCache = + MakePerIsolateVendorModuleCache(); + +// Set of canonical keys currently being resolved (loop-breaker for cyclic +// HTTP imports). thread_local because dynamic-import waits are isolate-bound. +namespace { +std::unordered_set& MakePerIsolateInFlightSet() { + thread_local auto* p = new std::unordered_set(); + return *p; +} +} // namespace +static thread_local std::unordered_set& g_modulesInFlight = + MakePerIsolateInFlightSet(); + +} // namespace tns // Forward declaration used by logging helper std::string GetApplicationPath(); @@ -55,7 +159,7 @@ static void LogHttpCompileDiagnostics(v8::Isolate* isolate, String::Utf8Value l8(isolate, maybeLine.ToLocalChecked()); if (*l8) srcLineStr = *l8; } - // Heuristics similar to iOS for quick triage + // Classify the failure for quick triage. if (msgStr.find("Unexpected identifier") != std::string::npos || msgStr.find("Unexpected token") != std::string::npos) { if (msgStr.find("export") != std::string::npos && @@ -98,90 +202,654 @@ static void LogHttpCompileDiagnostics(v8::Isolate* isolate, snippet.c_str()); } -// Helper: resolve relative or root-absolute spec against an HTTP(S) referrer URL. -// Returns empty string if resolution is not possible. -static std::string ResolveHttpRelative(const std::string& referrerUrl, const std::string& spec) { - if (referrerUrl.empty()) { - return std::string(); +// Resolution of relative / root-absolute import specifiers against an http(s) +// referrer lives in `HMRSupport.cpp` (`ResolveImportSpecifierAgainstUrl`); call +// sites below invoke `tns::ResolveImportSpecifierAgainstUrl(spec, referrer)`. + +// ──────────────────────────────────────────────────────────────────────────── +// Module-resolution helpers (ESM resolver hardening). Helpers referenced before +// their definition are forward-declared at the top of this file. +namespace tns { + +static inline bool StartsWith(const std::string& s, const char* prefix) { + size_t n = strlen(prefix); + return s.size() >= n && s.compare(0, n, prefix) == 0; +} + +static inline bool EndsWith(const std::string& s, const char* suffix) { + size_t n = strlen(suffix); + return s.size() >= n && s.compare(s.size() - n, n, suffix) == 0; +} + +// Synthetic-namespace keys (ns-vendor://, optional:, node:, blob:) are NOT +// filesystem paths. Treat them as opaque identifiers — never collapse, +// percent-decode, or path-normalize them — so they keep their exact registry +// identity through invalidation and reload. +static bool IsSyntheticNamespaceKey(const std::string& key) { + return StartsWith(key, "ns-vendor://") || StartsWith(key, "optional:") || + StartsWith(key, "node:") || StartsWith(key, "blob:"); +} + +// Returns true for any specifier with a leading URL scheme of the form +// `:` where the scheme contains no `/` (filesystem paths and bare +// specifiers stay false). Used by InitializeImportMetaObject to decide +// whether `import.meta.url` should preserve the specifier verbatim vs. +// wrap it in `file://`. +static bool HasUrlScheme(const std::string& spec) { + size_t schemePos = spec.find(':'); + if (schemePos == std::string::npos || schemePos == 0) return false; + size_t slashPos = spec.find('/'); + if (slashPos != std::string::npos && slashPos < schemePos) return false; + // Scheme chars must be alphanumeric / `+` / `-` / `.` per RFC 3986. + for (size_t i = 0; i < schemePos; ++i) { + char c = spec[i]; + if (!std::isalnum(static_cast(c)) && c != '+' && c != '-' && c != '.') { + return false; } - auto startsWith = [](const std::string& s, const char* pre) -> bool { - size_t n = strlen(pre); - return s.size() >= n && s.compare(0, n, pre) == 0; - }; - if (!(startsWith(referrerUrl, "http://") || startsWith(referrerUrl, "https://"))) { - return std::string(); - } - // Normalize referrer: drop fragment and query - std::string base = referrerUrl; - size_t hashPos = base.find('#'); - if (hashPos != std::string::npos) base = base.substr(0, hashPos); - size_t qPos = base.find('?'); - if (qPos != std::string::npos) base = base.substr(0, qPos); - - // Extract origin and path - size_t schemePos = base.find("://"); - if (schemePos == std::string::npos) { - return std::string(); - } - size_t pathStart = base.find('/', schemePos + 3); - std::string origin = (pathStart == std::string::npos) ? base : base.substr(0, pathStart); - std::string path = (pathStart == std::string::npos) ? std::string("/") : base.substr(pathStart); - - // Separate query/fragment from spec - std::string specPath = spec; - std::string specSuffix; - size_t specQ = specPath.find('?'); - size_t specH = specPath.find('#'); - size_t cut = std::string::npos; - if (specQ != std::string::npos && specH != std::string::npos) { - cut = std::min(specQ, specH); - } else if (specQ != std::string::npos) { - cut = specQ; - } else if (specH != std::string::npos) { - cut = specH; - } - if (cut != std::string::npos) { - specSuffix = specPath.substr(cut); - specPath = specPath.substr(0, cut); - } - - // Build new path - std::string newPath; - if (!specPath.empty() && specPath[0] == '/') { - // Root-absolute relative to origin - newPath = specPath; - } else { - // Relative to directory of referrer path - size_t lastSlash = path.find_last_of('/'); - std::string baseDir = (lastSlash == std::string::npos) ? std::string("/") : path.substr(0, lastSlash + 1); - newPath = baseDir + specPath; - } - - // Normalize "." and ".." segments - std::vector stack; - bool absolute = !newPath.empty() && newPath[0] == '/'; - size_t i = 0; - while (i <= newPath.size()) { - size_t j = newPath.find('/', i); - std::string seg = (j == std::string::npos) ? newPath.substr(i) : newPath.substr(i, j - i); - if (seg.empty() || seg == ".") { - // skip - } else if (seg == "..") { - if (!stack.empty()) stack.pop_back(); - } else { - stack.push_back(seg); + } + return true; +} + +// Cache-bypass check. Used by HttpFetchText's fast-path lookup to decide +// whether to ignore a hit. Patterns are simple substring matches, populated +// by Vite via `__nsConfigureRuntime({ volatilePatterns: [...] })`. +static bool IsVolatileUrl(const std::string& url) { + std::lock_guard lock(g_volatilePatternsMutex); + for (const auto& pat : g_volatilePatterns) { + if (url.find(pat) != std::string::npos) return true; + } + return false; +} + +// Canonicalize a raw module key into a stable registry key. +// +// Rules: +// - HTTP/HTTPS keys go through `CanonicalizeHttpUrlKey` (drop fragment, +// normalize bridge endpoints, sort query, strip `?import`). +// - `file://http(s)://...` keys unwrap the outer scheme, then HTTP-canon. +// - `blob:` keys are preserved verbatim (they're opaque random IDs). +// - `ns-vendor://`, `optional:`, `node:` (and any other custom scheme +// where the scheme appears before the first slash) are preserved +// verbatim — these are NOT filesystem paths. +// - Plain filesystem paths fall through unchanged (the Android resolver +// resolves them lazily via candidate scans). +static std::string CanonicalizeRegistryKey(const std::string& key) { + if (key.empty()) { + return key; + } + + if (StartsWith(key, "http://") || StartsWith(key, "https://") || + StartsWith(key, "file://http://") || StartsWith(key, "file://https://")) { + return CanonicalizeHttpUrlKey(key); + } + if (StartsWith(key, "blob:")) { + return key; + } + if (IsSyntheticNamespaceKey(key)) { + return key; + } + // Any other custom scheme, or a plain filesystem path: preserve verbatim. + // The Android resolver resolves filesystem paths lazily via candidate scans. + return key; +} + +// Compile a module body for "register only" mode used by the HTTP loader +// and vendor wrapper. Caller owns instantiation + evaluation; we just +// produce the v8::Module and seat it in `g_moduleRegistry` under +// `registryKey` so resolver callbacks can find it during link. +static v8::MaybeLocal CompileModuleForResolveRegisterOnly( + v8::Isolate* isolate, v8::Local context, + const std::string& source, const std::string& registryKey) { + v8::EscapableHandleScope scope(isolate); + v8::Local sourceText = ArgConverter::ConvertToV8String(isolate, source); + v8::Local urlString = ArgConverter::ConvertToV8String(isolate, registryKey); + v8::ScriptOrigin origin(isolate, urlString, 0, 0, false, -1, v8::Local(), false, false, + true /* is_module */); + v8::ScriptCompiler::Source src(sourceText, origin); + + v8::Local mod; + { + v8::TryCatch tc(isolate); + if (!v8::ScriptCompiler::CompileModule(isolate, &src).ToLocal(&mod)) { + if (IsScriptLoadingLogEnabled()) { + v8::Local ex = tc.Exception(); + v8::String::Utf8Value m(isolate, ex); + DEBUG_WRITE("[http-esm][compile-register][fail] key=%s msg=%s", + registryKey.c_str(), *m ? *m : "(unknown)"); + } + return v8::MaybeLocal(); + } + } + g_moduleRegistry[registryKey].Reset(isolate, mod); + return scope.Escape(mod); +} + +// Normalize a Vite-rewritten specifier into the canonical import-map key. +// Handles two common Vite dev-server rewrite patterns: +// 1. Prebundled deps: "/node_modules/.vite/deps/solid-js.js?v=abc" → "solid-js" +// "/node_modules/.vite/deps/@tanstack_solid-router.js" → "@tanstack/solid-router" +// 2. Explicit node_modules paths: +// "/node_modules/@angular/core/fesm2022/core.mjs" → "@angular/core/fesm2022/core.mjs" +// "/node_modules/tslib/tslib.es6.mjs" → "tslib" +// +// For explicit node_modules paths we preserve non-main-entry subpaths so the +// import map's trailing-slash HTTP prefixes can keep complex package build +// outputs on HTTP. Only bare package roots and simple root-level main entries +// collapse back to the package id for vendor/exact import-map resolution. +static std::string NormalizeViteSpecifier(const std::string& specifier) { + // Pattern 1: Vite prebundled deps — /node_modules/.vite/deps/.js + { + const std::string viteDepsPrefix = "/node_modules/.vite/deps/"; + const std::string viteDepsPrefix2 = "node_modules/.vite/deps/"; + std::string prefix; + if (specifier.compare(0, viteDepsPrefix.size(), viteDepsPrefix) == 0) + prefix = viteDepsPrefix; + else if (specifier.compare(0, viteDepsPrefix2.size(), viteDepsPrefix2) == 0) + prefix = viteDepsPrefix2; + + if (!prefix.empty()) { + std::string id = specifier.substr(prefix.size()); + auto qpos = id.find('?'); + if (qpos != std::string::npos) id = id.substr(0, qpos); + auto dotpos = id.rfind('.'); + if (dotpos != std::string::npos) id = id.substr(0, dotpos); + if (!id.empty() && id[0] == '@') { + auto upos = id.find('_'); + if (upos != std::string::npos) { + id = id.substr(0, upos) + "/" + id.substr(upos + 1); + auto upos2 = id.find('_', upos + 1); + if (upos2 != std::string::npos) { + id = id.substr(0, upos2); + } } - if (j == std::string::npos) break; - i = j + 1; + } + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[import-map][normalize] vite-deps: %s -> %s", specifier.c_str(), id.c_str()); + } + return id; } - std::string normPath = absolute ? "/" : std::string(); - for (size_t k = 0; k < stack.size(); k++) { - if (k > 0) normPath += "/"; - normPath += stack[k]; + } + + // Pattern 2: Resolved node_modules path — /node_modules//... + { + const std::string nmPrefix = "/node_modules/"; + const std::string nmPrefix2 = "node_modules/"; + std::string sub; + if (specifier.compare(0, nmPrefix.size(), nmPrefix) == 0) + sub = specifier.substr(nmPrefix.size()); + else if (specifier.compare(0, nmPrefix2.size(), nmPrefix2) == 0) + sub = specifier.substr(nmPrefix2.size()); + + if (!sub.empty() && sub[0] != '.') { + if (sub.compare(0, 6, ".vite/") == 0) return ""; + + std::string subNoQuery = sub; + std::string querySuffix; + auto subQueryPos = sub.find('?'); + if (subQueryPos != std::string::npos) { + subNoQuery = sub.substr(0, subQueryPos); + querySuffix = sub.substr(subQueryPos); + } + + std::string pkgName; + if (subNoQuery[0] == '@') { + auto slash1 = subNoQuery.find('/'); + if (slash1 != std::string::npos) { + auto slash2 = subNoQuery.find('/', slash1 + 1); + pkgName = (slash2 != std::string::npos) ? subNoQuery.substr(0, slash2) : subNoQuery; + } + } else { + auto slash = subNoQuery.find('/'); + pkgName = (slash != std::string::npos) ? subNoQuery.substr(0, slash) : subNoQuery; + } + if (!pkgName.empty()) { + std::string normalized = pkgName; + std::string remainder; + if (subNoQuery.size() > pkgName.size()) { + remainder = subNoQuery.substr(pkgName.size()); + if (!remainder.empty() && remainder[0] == '/') { + remainder.erase(0, 1); + } + } + + if (!remainder.empty()) { + bool preserveSubpath = remainder.find('/') != std::string::npos; + + if (!preserveSubpath) { + const std::string pkgBaseName = pkgName.substr(pkgName.find_last_of('/') + 1); + std::string withoutExt = remainder; + auto dot = withoutExt.rfind('.'); + if (dot != std::string::npos) { + withoutExt = withoutExt.substr(0, dot); + } + std::string withoutPlatform = withoutExt; + for (const char* suffix : {".ios", ".android", ".visionos"}) { + if (EndsWith(withoutPlatform, suffix)) { + withoutPlatform = withoutPlatform.substr(0, withoutPlatform.size() - strlen(suffix)); + break; + } + } + const bool isRootLevelMainEntry = withoutPlatform == "index" || + withoutPlatform == pkgBaseName || + withoutPlatform.rfind(pkgBaseName + ".", 0) == 0; + preserveSubpath = !isRootLevelMainEntry; + } + + if (preserveSubpath) { + normalized = pkgName + "/" + remainder + querySuffix; + } + } + + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[import-map][normalize] node_modules: %s -> %s", specifier.c_str(), normalized.c_str()); + } + return normalized; + } + } + } + + return ""; +} + +// Look up a specifier in the import map. Supports both exact matches and +// prefix matches (trailing-slash entries like "solid-js/" that map subpaths). +// Returns the mapped URL or empty string if no match. +static std::string LookupImportMap(const std::string& specifier) { + std::lock_guard lock(g_importMapMutex); + auto it = g_importMap.find(specifier); + if (it != g_importMap.end()) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[import-map] exact: %s -> %s", specifier.c_str(), it->second.c_str()); + } + return it->second; + } + std::string bestKey; + std::string bestValue; + for (const auto& kv : g_importMap) { + const std::string& key = kv.first; + if (key.empty() || key.back() != '/') continue; + if (specifier.size() > key.size() && specifier.compare(0, key.size(), key) == 0) { + if (key.size() > bestKey.size()) { + bestKey = key; + bestValue = kv.second; + } + } + } + if (!bestKey.empty()) { + std::string remainder = specifier.substr(bestKey.size()); + std::string resolved = bestValue + remainder; + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[import-map] prefix: %s -> %s (via %s)", specifier.c_str(), resolved.c_str(), bestKey.c_str()); + } + return resolved; + } + return ""; +} + +// Escape `s` as a single-quoted JS string literal. Returns the literal +// including the surrounding quotes so call sites can splice it directly +// into a generated source string (e.g. `"foo(" + JsStringLiteral(id) + ")"`). +// Handles backslash, single quote, the JS line terminators (\n, \r, +// U+2028, U+2029), and other ASCII control characters via `\xNN`. +static std::string JsStringLiteral(const std::string& s) { + std::string out; + out.reserve(s.size() + 2); + out.push_back('\''); + for (size_t i = 0; i < s.size(); ) { + unsigned char c = static_cast(s[i]); + if (c == '\\') { out += "\\\\"; ++i; continue; } + if (c == '\'') { out += "\\'"; ++i; continue; } + if (c == '\n') { out += "\\n"; ++i; continue; } + if (c == '\r') { out += "\\r"; ++i; continue; } + if (c == 0xE2 && i + 2 < s.size() && + static_cast(s[i + 1]) == 0x80 && + (static_cast(s[i + 2]) == 0xA8 || + static_cast(s[i + 2]) == 0xA9)) { + out += (static_cast(s[i + 2]) == 0xA8) ? "\\u2028" : "\\u2029"; + i += 3; + continue; + } + if (c < 0x20) { + char buf[7]; + std::snprintf(buf, sizeof(buf), "\\x%02X", c); + out += buf; + ++i; + continue; + } + out.push_back(static_cast(c)); + ++i; + } + out.push_back('\''); + return out; +} + +// Helper: returns true if `name` is a valid JS identifier that can appear in +// `export const = ...` without quoting. Conservative check — rejects +// anything that could cause a parse error in the generated ESM wrapper. +static bool IsValidJSIdentifier(const std::string& name) { + if (name.empty()) return false; + char first = name[0]; + if (!((first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') || + first == '_' || first == '$')) + return false; + for (size_t i = 1; i < name.size(); i++) { + char c = name[i]; + if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '_' || c == '$')) + return false; + } + return true; +} + +// Create an ESM wrapper that re-exports all named exports from the vendor +// registry. The vendor bootstrap (JS side) populates +// globalThis.__nsVendorRegistry with pre-bundled module namespace objects +// (via `import * as`). This function enumerates the actual property names +// of the vendor module and generates explicit `export const X = __mod['X'];` +// statements so V8's ESM resolution finds every named export. +static v8::MaybeLocal ResolveFromVendorRegistry(v8::Isolate* isolate, + v8::Local context, + const std::string& vendorId) { + auto cached = g_vendorModuleCache.find(vendorId); + if (cached != g_vendorModuleCache.end()) { + v8::Local mod = cached->second.Get(isolate); + if (!mod.IsEmpty() && mod->GetStatus() != v8::Module::kErrored) { + return mod; + } + cached->second.Reset(); + g_vendorModuleCache.erase(cached); + } + + std::vector exportNames; + + v8::TryCatch tc(isolate); + do { + v8::Local global = context->Global(); + + v8::Local regVal; + if (!global->Get(context, ArgConverter::ConvertToV8String(isolate, "__nsVendorRegistry")).ToLocal(®Val) || + regVal->IsNullOrUndefined()) { + break; + } + v8::Local registry = regVal.As(); + + v8::Local getFnVal; + if (!registry->Get(context, ArgConverter::ConvertToV8String(isolate, "get")).ToLocal(&getFnVal) || + !getFnVal->IsFunction()) { + break; } - return origin + normPath + specSuffix; + v8::Local getArgs[] = { ArgConverter::ConvertToV8String(isolate, vendorId) }; + v8::Local modVal; + if (!getFnVal.As()->Call(context, registry, 1, getArgs).ToLocal(&modVal) || + modVal->IsNullOrUndefined()) { + break; + } + + v8::Local modObj = modVal.As(); + v8::Local keys; + if (!modObj->GetOwnPropertyNames(context).ToLocal(&keys)) { + break; + } + + for (uint32_t i = 0; i < keys->Length(); i++) { + v8::Local key; + if (!keys->Get(context, i).ToLocal(&key) || !key->IsString()) continue; + v8::String::Utf8Value keyUtf8(isolate, key); + if (!*keyUtf8) continue; + std::string name(*keyUtf8); + if (name != "default" && IsValidJSIdentifier(name)) { + exportNames.push_back(name); + } + } + } while (false); + + if (tc.HasCaught()) { + tc.Reset(); + } + + std::string moduleKey = "ns-vendor://" + vendorId; + // Two failure modes are distinguished so the runtime error names the + // class of problem: registry not yet populated (wrapper evaluated + // before `installVendorBootstrap()` ran) vs. specifier absent from a + // populated registry (vendor bundle does not ship this entry). + // `vendorId` is escaped through `JsStringLiteral` so any character is + // safe to embed inside the generated JS source. + const std::string idLiteral = JsStringLiteral(vendorId); + std::string src = + "const __reg = globalThis.__nsVendorRegistry;\n" + "if (!__reg || __reg.size === 0) {\n" + " throw new Error('ns-vendor wrapper ' + " + idLiteral + + " + ' evaluated before __nsVendorRegistry was populated');\n" + "}\n" + "const __mod = __reg.get(" + idLiteral + ");\n" + "if (!__mod) {\n" + " throw new Error('ns-vendor specifier ' + " + idLiteral + + " + ' not in __nsVendorRegistry (' + __reg.size + ' entries)');\n" + "}\n" + "export default __mod.default !== undefined ? __mod.default : __mod;\n"; + + for (const auto& name : exportNames) { + src += "export const " + name + " = __mod[" + JsStringLiteral(name) + "];\n"; + } + + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[import-map][vendor] generating wrapper for ns-vendor://%s with %lu named exports", + vendorId.c_str(), (unsigned long)exportNames.size()); + } + + v8::MaybeLocal m = CompileModuleForResolveRegisterOnly(isolate, context, src, moduleKey); + if (!m.IsEmpty()) { + v8::Local mod; + if (m.ToLocal(&mod)) { + g_vendorModuleCache[vendorId].Reset(isolate, mod); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[import-map][vendor] resolved ns-vendor://%s", vendorId.c_str()); + } + } + } + return m; } +// Public: register import-map JSON blob. The full JSON shape is +// `{"imports": {"": "", ...}}`. The flat shape is walked +// directly so this module does not need to depend on a JSON parser. +void SetImportMap(const std::string& json) { + std::lock_guard lock(g_importMapMutex); + g_importMap.clear(); + size_t importsPos = json.find("\"imports\""); + if (importsPos == std::string::npos) return; + size_t braceOpen = json.find('{', importsPos + 9); + if (braceOpen == std::string::npos) return; + // Find matching close brace, accounting for nested values (we still only + // support flat key->string, but the body may contain escaped quotes). + int depth = 1; + size_t i = braceOpen + 1; + size_t braceClose = std::string::npos; + bool inString = false; + while (i < json.size()) { + char c = json[i]; + if (inString) { + if (c == '\\' && i + 1 < json.size()) { i += 2; continue; } + if (c == '"') inString = false; + } else { + if (c == '"') inString = true; + else if (c == '{') ++depth; + else if (c == '}') { + --depth; + if (depth == 0) { braceClose = i; break; } + } + } + ++i; + } + if (braceClose == std::string::npos) return; + + std::string inner = json.substr(braceOpen + 1, braceClose - braceOpen - 1); + size_t pos = 0; + while (pos < inner.size()) { + size_t keyStart = inner.find('"', pos); + if (keyStart == std::string::npos) break; + size_t keyEnd = inner.find('"', keyStart + 1); + if (keyEnd == std::string::npos) break; + std::string key = inner.substr(keyStart + 1, keyEnd - keyStart - 1); + + size_t valStart = inner.find('"', keyEnd + 1); + if (valStart == std::string::npos) break; + size_t valEnd = inner.find('"', valStart + 1); + if (valEnd == std::string::npos) break; + std::string val = inner.substr(valStart + 1, valEnd - valStart - 1); + + g_importMap[key] = val; + pos = valEnd + 1; + } + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[import-map] loaded %lu entries", (unsigned long)g_importMap.size()); + } +} + +void SetVolatilePatterns(const std::vector& patterns) { + std::lock_guard lock(g_volatilePatternsMutex); + g_volatilePatterns = patterns; + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[import-map] volatile patterns: %lu", (unsigned long)g_volatilePatterns.size()); + } +} + +void CleanupImportMapGlobals() { + { + std::lock_guard lock(g_importMapMutex); + g_importMap.clear(); + } + { + std::lock_guard lock(g_volatilePatternsMutex); + g_volatilePatterns.clear(); + } + for (auto& kv : g_vendorModuleCache) { kv.second.Reset(); } + g_vendorModuleCache.clear(); + g_modulesInFlight.clear(); +} + +std::vector GetLoadedModuleUrls() { + std::vector urls; + urls.reserve(g_moduleRegistry.size()); + for (const auto& kv : g_moduleRegistry) { + if (!kv.first.empty()) urls.push_back(kv.first); + } + return urls; +} + +void RemoveModuleFromRegistry(const std::string& canonicalKey) { + const std::string registryKey = CanonicalizeRegistryKey(canonicalKey); + // Defensive: never wipe a sentinel key. + if (registryKey == "@" || + registryKey.find("__invalid_at__.mjs") != std::string::npos) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver][guard] ignore remove for sentinel %s", registryKey.c_str()); + } + return; + } + + auto it = g_moduleRegistry.find(registryKey); + if (it != g_moduleRegistry.end()) { + bool isHttpKey = StartsWith(registryKey, "http://") || StartsWith(registryKey, "https://"); + if (IsScriptLoadingLogEnabled() && !isHttpKey) { + DEBUG_WRITE("[resolver] removing stale module %s", registryKey.c_str()); + } + it->second.Reset(); + g_moduleRegistry.erase(it); + } +} + +size_t InvalidateModules(const std::vector& keys) { + size_t removed = 0; + std::vector urlsToEvict; + urlsToEvict.reserve(keys.size()); + for (const auto& raw : keys) { + if (raw.empty()) continue; + const std::string registryKey = CanonicalizeRegistryKey(raw); + auto it = g_moduleRegistry.find(registryKey); + if (it != g_moduleRegistry.end()) { + it->second.Reset(); + g_moduleRegistry.erase(it); + ++removed; + } + if (StartsWith(registryKey, "http://") || StartsWith(registryKey, "https://")) { + urlsToEvict.push_back(registryKey); + } + } + if (!urlsToEvict.empty()) { + EvictHttpModulePrefetchCacheUrls(urlsToEvict); + } + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver][invalidate] requested=%lu removed=%lu", + (unsigned long)keys.size(), (unsigned long)removed); + } + return removed; +} + +v8::MaybeLocal LoadHttpModuleForUrl(v8::Isolate* isolate, + v8::Local context, + const std::string& url, + std::string* errorMessage) { + if (url.empty()) { + if (errorMessage) *errorMessage = "[http-esm][load] empty URL"; + return v8::MaybeLocal(); + } + const std::string registryKey = CanonicalizeRegistryKey(url); + + auto it = g_moduleRegistry.find(registryKey); + if (it != g_moduleRegistry.end()) { + return it->second.Get(isolate); + } + + // Loop-breaker: if we're already fetching this URL inside this isolate + // (cyclic HTTP import), don't recurse. The caller's outer fetch will + // populate the registry; on the second pass our cache lookup above will + // succeed. + if (g_modulesInFlight.count(registryKey) > 0) { + if (errorMessage) *errorMessage = "[http-esm][load] cyclic-inflight " + registryKey; + return v8::MaybeLocal(); + } + g_modulesInFlight.insert(registryKey); + + std::string body; + std::string contentType; + int status = 0; + bool ok = HttpFetchText(url, body, contentType, status) && !body.empty(); + g_modulesInFlight.erase(registryKey); + + if (!ok) { + if (errorMessage) { + *errorMessage = std::string("[http-esm][load] fetch-fail ") + url + + " status=" + std::to_string(status); + } + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[http-esm][load][fetch-fail] request=%s key=%s status=%d", + url.c_str(), registryKey.c_str(), status); + } + return v8::MaybeLocal(); + } + + v8::MaybeLocal loaded = + CompileModuleForResolveRegisterOnly(isolate, context, body, registryKey); + if (loaded.IsEmpty()) { + if (errorMessage) { + *errorMessage = std::string("[http-esm][load] compile-fail ") + url; + } + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[http-esm][load][compile-fail] request=%s key=%s bytes=%zu", + url.c_str(), registryKey.c_str(), body.size()); + } + return v8::MaybeLocal(); + } + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[http-esm][load][ok] request=%s key=%s type=%s bytes=%zu", + url.c_str(), registryKey.c_str(), contentType.c_str(), body.size()); + } + return loaded; +} + +} // namespace tns + // Import meta callback to support import.meta.url and import.meta.dirname void InitializeImportMetaObject(Local context, Local module, Local meta) { Isolate* isolate = context->GetIsolate(); @@ -213,11 +881,21 @@ void InitializeImportMetaObject(Local context, Local module, Lo DEBUG_WRITE("InitializeImportMetaObject: Registry size: %zu", g_moduleRegistry.size()); } - // Convert to URL for import.meta.url; keep http(s) untouched, file paths with file:// + // Convert to URL for import.meta.url: + // - http(s) keys keep the URL verbatim + // - Synthetic-namespace keys (`node:`, `blob:`, `ns-vendor://`, + // `optional:`) MUST preserve their identity — wrapping them in + // `file://` would make `import.meta.url` decode them as filesystem + // paths in user code, which is wrong (they're not files). + // - Any other URL-schemed specifier (`:` before any `/`) + // also passes through verbatim (`tns::HasUrlScheme` check). + // - File-system paths get the standard `file://` wrap. std::string moduleUrl; if (!modulePath.empty()) { if (modulePath.rfind("http://", 0) == 0 || modulePath.rfind("https://", 0) == 0) { moduleUrl = modulePath; + } else if (tns::HasUrlScheme(modulePath)) { + moduleUrl = modulePath; } else { moduleUrl = "file://" + modulePath; } @@ -235,15 +913,22 @@ void InitializeImportMetaObject(Local context, Local module, Lo // Set import.meta.url property meta->CreateDataProperty(context, ArgConverter::ConvertToV8String(isolate, "url"), url).Check(); - // Add import.meta.dirname support (extract directory) + // Add import.meta.dirname support (extract directory). + // + // For synthetic-namespace keys (node:, blob:, ns-vendor://, optional:) + // the concept of a "directory" doesn't apply — the module isn't a file + // on disk. dirname falls back to the full URL so user code that joins + // paths still produces a recognizable key (and `node:url` / + // `blob:abc-def` etc. don't accidentally read as filesystem prefixes). std::string dirname; if (!modulePath.empty()) { if (modulePath.rfind("http://", 0) == 0 || modulePath.rfind("https://", 0) == 0) { - // For URLs, compute dirname by trimming after last '/' size_t q = modulePath.find('?'); std::string noQuery = (q == std::string::npos) ? modulePath : modulePath.substr(0, q); size_t lastSlash = noQuery.find_last_of('/'); dirname = (lastSlash == std::string::npos) ? modulePath : noQuery.substr(0, lastSlash); + } else if (tns::HasUrlScheme(modulePath)) { + dirname = modulePath; // synthetic — no real directory } else { size_t lastSlash = modulePath.find_last_of("/\\"); if (lastSlash != std::string::npos) { @@ -261,8 +946,16 @@ void InitializeImportMetaObject(Local context, Local module, Lo // Set import.meta.dirname property meta->CreateDataProperty(context, ArgConverter::ConvertToV8String(isolate, "dirname"), dirnameStr).Check(); - // Attach import.meta.hot for HMR - tns::InitializeImportMetaHot(isolate, context, meta, modulePath); + // Attach import.meta.hot for HMR — debug/dev builds only. In a release + // build the HMR client and dev-session globals are not installed (see the + // isDebuggable gate in Runtime::PrepareV8Runtime), so this per-module hot + // surface would be inert dead weight on every module. Gate it on + // isDebuggable so production modules carry only import.meta.url/dirname. + // Standard HMR code always guards with `if (import.meta.hot)`, so leaving + // it undefined in release is the conventional, safe behavior. + if (tns::IsDebuggable()) { + tns::InitializeImportMetaHot(isolate, context, meta, modulePath); + } } // Helper function to check if a file exists and is a regular file @@ -316,13 +1009,115 @@ v8::MaybeLocal ResolveModuleCallback(v8::Local context, DEBUG_WRITE("ResolveModuleCallback: Resolving '%s'", spec.c_str()); } - // Normalize malformed http:/ and https:/ prefixes + // ── ESM resolver hardening ───────────────────────────────────────────── + // Heal common bundler-rewrite anomalies BEFORE any registry lookup so + // identity-mismatched keys never enter `g_moduleRegistry`. + // + // 1. A lone "@" (bundler dropped the package id) → rewrite to a + // sentinel string that downstream resolvers ignore. + // 2. "@/" (root-absolute alias the dev server didn't expand) + // → strip the prefix so the path lookup operates on "/". + // 3. Malformed `http:/` (one slash, often from Vite stripping + // the second slash through string ops) → re-insert. + if (spec == "@") { + // Sentinel — never matches a real module. Synthesize a clear + // failure so the user's error trace anchors to the bad import. + if (tns::IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver][guard] bare-@ specifier; returning empty"); + } + return v8::MaybeLocal(); + } + if (spec.size() >= 2 && spec[0] == '@' && spec[1] == '/') { + std::string rewritten = spec.substr(1); // "@/foo" → "/foo" + if (tns::IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver][guard] rewrite @/ -> / for spec=%s -> %s", + spec.c_str(), rewritten.c_str()); + } + spec = rewritten; + } if (spec.rfind("http:/", 0) == 0 && spec.rfind("http://", 0) != 0) { spec.insert(5, "/"); } else if (spec.rfind("https:/", 0) == 0 && spec.rfind("https://", 0) != 0) { spec.insert(6, "/"); } + // ── Import-map lookup (bare specifiers only) ────────────────────────── + // Bare specifiers (no `.`, `/`, scheme) are mapped through the dev + // server's import map before anything else. The map can resolve to: + // - `ns-vendor://` → SyntheticModule via vendor registry + // - `http(s)://...` → HTTP loader path below + // - `` → falls through to filesystem candidate scan + // Vite-rewritten `/node_modules/...` specifiers are normalized via + // `NormalizeViteSpecifier` first so the import-map key matches. + if (!spec.empty() && spec[0] != '.' && spec[0] != '/' && + spec.find("://") == std::string::npos && !tns::HasUrlScheme(spec)) { + std::string lookupKey = spec; + std::string vNorm = tns::NormalizeViteSpecifier(spec); + if (!vNorm.empty()) lookupKey = vNorm; + std::string mapped = tns::LookupImportMap(lookupKey); + if (!mapped.empty()) { + if (tns::IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[import-map][hit] %s -> %s", spec.c_str(), mapped.c_str()); + } + spec = mapped; + } + } else if (!spec.empty() && spec[0] == '/' && + spec.find("://") == std::string::npos && + spec.find("/node_modules/") != std::string::npos) { + // Root-absolute node_modules path — try import-map after normalization. + std::string vNorm = tns::NormalizeViteSpecifier(spec); + if (!vNorm.empty()) { + std::string mapped = tns::LookupImportMap(vNorm); + if (!mapped.empty()) { + if (tns::IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[import-map][hit-root-abs] %s -> %s (via %s)", + spec.c_str(), mapped.c_str(), vNorm.c_str()); + } + spec = mapped; + } + } + } + + // ── Synthetic-namespace identity preservation ───────────────────────── + // ns-vendor:// / optional: / node: / blob: are NOT filesystem paths. + // Resolve them via dedicated paths instead of falling through to the + // candidate-scan logic below (which would try to stat them as files). + if (spec.rfind("ns-vendor://", 0) == 0) { + std::string vendorId = spec.substr(strlen("ns-vendor://")); + v8::Local vendorMod; + if (tns::ResolveFromVendorRegistry(isolate, context, vendorId).ToLocal(&vendorMod)) { + return v8::MaybeLocal(vendorMod); + } + // Vendor registry doesn't have it — throw a clear "not found" so the + // import rejects with a useful message instead of a stat-as-file miss. + std::string msg = "Vendor module not found: " + spec; + isolate->ThrowException(v8::Exception::Error( + ArgConverter::ConvertToV8String(isolate, msg))); + return v8::MaybeLocal(); + } + if (spec.rfind("blob:", 0) == 0) { + // Blob URLs are issued by URL.createObjectURL — the corresponding + // module should already be in g_moduleRegistry under the exact blob + // key (loader wrote it on creation). Look up verbatim; no + // normalization (the random ID is the identity). + auto it = g_moduleRegistry.find(spec); + if (it != g_moduleRegistry.end()) { + return v8::MaybeLocal(it->second.Get(isolate)); + } + std::string msg = "Blob module not found: " + spec; + isolate->ThrowException(v8::Exception::Error( + ArgConverter::ConvertToV8String(isolate, msg))); + return v8::MaybeLocal(); + } + if (spec.rfind("optional:", 0) == 0) { + // Optional-module sentinel — return empty so V8 throws the standard + // ESM resolve failure (the caller is responsible for swallowing). + if (tns::IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver][optional] not found: %s", spec.c_str()); + } + return v8::MaybeLocal(); + } + // Attempt to resolve relative or root-absolute specifiers against an HTTP referrer URL std::string referrerPath; for (auto& kv : g_moduleRegistry) { @@ -339,7 +1134,7 @@ v8::MaybeLocal ResolveModuleCallback(v8::Local context, }; if (!startsWithHttp(spec) && (specIsRelative || specIsRootAbs)) { if (!referrerPath.empty() && startsWithHttp(referrerPath)) { - std::string resolved = ResolveHttpRelative(referrerPath, spec); + std::string resolved = tns::ResolveImportSpecifierAgainstUrl(spec, referrerPath); if (!resolved.empty()) { if (IsScriptLoadingLogEnabled()) { DEBUG_WRITE("ResolveModuleCallback: HTTP-relative resolved '%s' + '%s' -> '%s'", @@ -359,7 +1154,7 @@ v8::MaybeLocal ResolveModuleCallback(v8::Local context, if (!origin.empty() && (origin.rfind("http://", 0) == 0 || origin.rfind("https://", 0) == 0)) { std::string refBase = origin; if (refBase.back() != '/') refBase += '/'; - std::string resolved = ResolveHttpRelative(refBase, spec); + std::string resolved = tns::ResolveImportSpecifierAgainstUrl(spec, refBase); if (!resolved.empty()) { if (IsScriptLoadingLogEnabled()) { DEBUG_WRITE("[http-esm][http-origin][fallback] origin=%s spec=%s -> %s", refBase.c_str(), spec.c_str(), resolved.c_str()); @@ -389,10 +1184,19 @@ v8::MaybeLocal ResolveModuleCallback(v8::Local context, std::string body, ct; int status = 0; if (!tns::HttpFetchText(spec, body, ct, status)) { + // Pull any JNI-captured reason BEFORE composing the error so + // the user sees `connect failed: ECONNREFUSED` (or whatever) + // alongside the bare `status=0` instead of having to dig + // through logcat. + std::string reason = tns::TakeLastHttpFetchErrorReason(); if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][fetch][fail] url=%s status=%d", spec.c_str(), status); + DEBUG_WRITE("[http-esm][fetch][fail] url=%s status=%d reason=%s", + spec.c_str(), status, reason.c_str()); } std::string msg = std::string("Failed to fetch ") + spec + ", status=" + std::to_string(status); + if (!reason.empty()) { + msg += ", " + reason; + } isolate->ThrowException(v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg))); return v8::MaybeLocal(); } @@ -856,7 +1660,7 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( referrerUrl = *r8 ? *r8 : ""; } if ((specIsRelative || specIsRootAbs) && isHttpLike(referrerUrl)) { - std::string resolved = ResolveHttpRelative(referrerUrl, spec); + std::string resolved = tns::ResolveImportSpecifierAgainstUrl(spec, referrerUrl); if (!resolved.empty()) { if (IsScriptLoadingLogEnabled()) { DEBUG_WRITE("[http-esm][dyn][http-rel] base=%s spec=%s -> %s", referrerUrl.c_str(), spec.c_str(), resolved.c_str()); @@ -867,6 +1671,42 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( } } + // Blob URL dynamic import — synthetic, must preserve identity (no + // canonicalization). The HTTP/file machinery below would mis-handle a + // `blob:` key as a fetch target. Caller already registered the module + // under the exact blob key when URL.createObjectURL was called. + if (spec.rfind("blob:", 0) == 0) { + auto it = g_moduleRegistry.find(spec); + if (it == g_moduleRegistry.end()) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][blob][miss] %s", spec.c_str()); + } + resolver->Reject(context, + v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, std::string("Blob module not found: ") + spec))).Check(); + return scope.Escape(resolver->GetPromise()); + } + v8::Local blobMod = it->second.Get(isolate); + if (blobMod->GetStatus() == v8::Module::kUninstantiated) { + if (!blobMod->InstantiateModule(context, &ResolveModuleCallback).FromMaybe(false)) { + resolver->Reject(context, + v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "Blob module instantiate failed"))).Check(); + return scope.Escape(resolver->GetPromise()); + } + } + if (blobMod->GetStatus() != v8::Module::kEvaluated) { + if (blobMod->Evaluate(context).IsEmpty()) { + resolver->Reject(context, + v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "Blob module evaluation failed"))).Check(); + return scope.Escape(resolver->GetPromise()); + } + } + resolver->Resolve(context, blobMod->GetModuleNamespace()).Check(); + return scope.Escape(resolver->GetPromise()); + } + // Handle HTTP(S) dynamic import directly // Security: HttpFetchText gates remote module access centrally. if (!spec.empty() && isHttpLike(spec)) { @@ -879,15 +1719,21 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( if (it != g_moduleRegistry.end()) { mod = it->second.Get(isolate); if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][dyn][cache] hit %s", canonical.c_str()); + DEBUG_WRITE("[http-esm][dyn][http-cache hit] %s", canonical.c_str()); } } else { std::string body, ct; int status = 0; if (!tns::HttpFetchText(spec, body, ct, status)) { + std::string reason = tns::TakeLastHttpFetchErrorReason(); if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][dyn][fetch][fail] url=%s status=%d", spec.c_str(), status); + DEBUG_WRITE("[http-esm][dyn][fetch][fail] url=%s status=%d reason=%s", + spec.c_str(), status, reason.c_str()); + } + std::string rejMsg = std::string("Failed to fetch ") + spec + ", status=" + std::to_string(status); + if (!reason.empty()) { + rejMsg += ", " + reason; } - resolver->Reject(context, v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, std::string("Failed to fetch ")+spec))).Check(); + resolver->Reject(context, v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, rejMsg))).Check(); return scope.Escape(resolver->GetPromise()); } if (IsScriptLoadingLogEnabled()) { diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h index 908c30ba7..76ec986d6 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h @@ -3,6 +3,63 @@ #include "v8.h" +#include +#include +#include + +namespace tns { + +// Per-isolate ES module registry. `thread_local`: each NS isolate (main thread + +// each Worker thread) gets its own per-thread map, because v8::Global +// handles are isolate-bound — sharing one map across isolates would let one +// thread try to read another thread's handle and trip V8's identity-check on +// instantiation. See the long-form comment above the definition in +// ModuleInternalCallbacks.cpp for the cross-isolate-handle bug this prevents. +extern thread_local std::unordered_map>& g_moduleRegistry; + +// Import-map and volatile-pattern configuration. +// +// `SetImportMap` accepts the dev server's JSON import-map blob (parsed and +// merged into the process-wide bare-specifier → URL map used by +// `ResolveModuleCallback`). `SetVolatilePatterns` accepts a list of URL +// substrings that should always re-fetch (never serve from the +// speculative-prefetch cache). Both are applied via `__nsConfigureRuntime` +// / `__nsConfigureDevRuntime` at session start and again at every HMR +// graph version bump. +void SetImportMap(const std::string& json); +void SetVolatilePatterns(const std::vector& patterns); + +// Drop all per-process import-map / vendor / in-flight state. +// Called from `Runtime::~Runtime()` on the MAIN isolate only — workers +// share the process-wide registries but the main isolate owns their +// lifetime, so wiping them on a worker would race with the next +// re-launched main isolate. +void CleanupImportMapGlobals(); + +// Snapshot the keys currently registered in `g_moduleRegistry` (file paths + +// canonical HTTP URLs). Used by `CollectSessionModuleUrls` to enumerate +// modules the dev session needs to invalidate. +std::vector GetLoadedModuleUrls(); + +// Evict the given keys (canonical registry keys) from `g_moduleRegistry`. +// No-op if the key is missing. Used by `__nsInvalidateModules` and by +// the HMR cycle to drop stale modules before re-importing. +void RemoveModuleFromRegistry(const std::string& canonicalKey); + +// Drop a list of keys + their HTTP cache entries in one pass. Returns the +// number of registry entries removed. +size_t InvalidateModules(const std::vector& keys); + +// Compile + register-only path used by the speculative HTTP loader so that +// a module can be cached without being instantiated/evaluated. The caller +// is responsible for instantiation + evaluation on the JS thread. +v8::MaybeLocal LoadHttpModuleForUrl(v8::Isolate* isolate, + v8::Local context, + const std::string& url, + std::string* errorMessage); + +} // namespace tns + // Module resolution callback for ES modules v8::MaybeLocal ResolveModuleCallback(v8::Local context, v8::Local specifier, diff --git a/test-app/runtime/src/main/cpp/Runtime.cpp b/test-app/runtime/src/main/cpp/Runtime.cpp index df0fb6e60..231fcecdd 100644 --- a/test-app/runtime/src/main/cpp/Runtime.cpp +++ b/test-app/runtime/src/main/cpp/Runtime.cpp @@ -3,9 +3,12 @@ #include #include #include +#include +#include #include #include +#include #include #include #include @@ -28,6 +31,8 @@ #include "SimpleAllocator.h" #include "SimpleProfiler.h" #include "URLImpl.h" +#include "HMRSupport.h" +#include "ModuleInternalCallbacks.h" #include "URLPatternImpl.h" #include "URLSearchParamsImpl.h" #include "Util.h" @@ -52,22 +57,122 @@ using namespace tns; bool tns::LogEnabled = true; SimpleAllocator g_allocator; -void SIG_handler(int sigNumber) { - stringstream msg; - msg << "JNI Exception occurred ("; +namespace { +struct SigHandlerBacktraceState { + void** current; + void** end; +}; + +// libunwind callback that walks the C++ stack frame-by-frame. Captures the +// PC for each frame into the passed array up to capacity. Signal-handler +// safe (does not allocate, does not call into libc beyond the unwinder). +_Unwind_Reason_Code SigHandlerUnwindCallback(struct _Unwind_Context* ctx, void* arg) { + auto* state = static_cast(arg); + uintptr_t pc = _Unwind_GetIP(ctx); + if (pc) { + if (state->current == state->end) { + return _URC_END_OF_STACK; + } + *state->current++ = reinterpret_cast(pc); + } + return _URC_NO_REASON; +} + +// Format one backtrace frame to logcat. We use dladdr to resolve the shared +// object and symbol; if the symbol is mangled we attempt to demangle. +// Output looks like: +// #07 pc 0x00000000004a8b0c libNativeScript.so (tns::Runtime::Init+12) +// Matching the format Android's stock crash dump uses so it's familiar. +void LogBacktraceFrame(int idx, void* pc) { + Dl_info info{}; + const char* libName = "?"; + const char* symName = "?"; + uintptr_t relPc = reinterpret_cast(pc); + uintptr_t symOff = 0; + char* demangled = nullptr; + + if (dladdr(pc, &info) && info.dli_fname) { + libName = info.dli_fname; + if (info.dli_fbase) { + relPc = relPc - reinterpret_cast(info.dli_fbase); + } + if (info.dli_sname) { + symName = info.dli_sname; + symOff = reinterpret_cast(pc) - reinterpret_cast(info.dli_saddr); + int status = 0; + demangled = abi::__cxa_demangle(info.dli_sname, nullptr, nullptr, &status); + if (status == 0 && demangled) { + symName = demangled; + } + } + } + + __android_log_print(ANDROID_LOG_FATAL, "TNS.Native", + " #%02d pc 0x%016lx %s (%s+%lu)", idx, + static_cast(relPc), libName, symName, + static_cast(symOff)); + if (demangled) free(demangled); +} +} // namespace + +void SIG_handler(int sigNumber, siginfo_t* sigInfo, void* /*ucontext*/) { + // Reset all fatal signal handlers to default IMMEDIATELY. If the body of + // this handler itself crashes (e.g. malloc is broken because the original + // SEGV was in the allocator), the second signal will kill the process + // cleanly and let Android write a proper tombstone, instead of looping + // forever between handler and signal. + ::signal(SIGABRT, SIG_DFL); + ::signal(SIGSEGV, SIG_DFL); + ::signal(SIGBUS, SIG_DFL); + ::signal(SIGFPE, SIG_DFL); + ::signal(SIGILL, SIG_DFL); + + // Async-signal-handler caveats: we intentionally avoid std::stringstream + // and other allocating code on the signal stack. __android_log_print and + // dladdr are not strictly signal-safe but are reliable on Android in + // practice and give us the diagnostic we need. + const char* sigName = "UNKNOWN"; switch (sigNumber) { - case SIGABRT: - msg << "SIGABRT"; - break; - case SIGSEGV: - msg << "SIGSEGV"; - break; - default: - // Shouldn't happen, but for completeness - msg << "Signal #" << sigNumber; - break; - } - msg << ").\n=======\nCheck the 'adb logcat' for additional information about " + case SIGABRT: sigName = "SIGABRT"; break; + case SIGSEGV: sigName = "SIGSEGV"; break; + case SIGBUS: sigName = "SIGBUS"; break; + case SIGFPE: sigName = "SIGFPE"; break; + case SIGILL: sigName = "SIGILL"; break; + default: sigName = "Signal"; break; + } + + // ── Header (must precede backtrace so it isn't lost when the handler + // throws and unwinds the stack). ────────────────────────────────── + __android_log_print(ANDROID_LOG_FATAL, "TNS.Native", + "=== Native crash caught by NativeScript runtime ==="); + __android_log_print(ANDROID_LOG_FATAL, "TNS.Native", + "signal %d (%s), code %d, fault addr %p, tid %d", + sigNumber, sigName, + sigInfo ? sigInfo->si_code : -1, + sigInfo ? sigInfo->si_addr : nullptr, + static_cast(gettid())); + + // ── C++ backtrace via _Unwind_Backtrace. Capped at 64 frames so we + // never run the signal stack out. ──────────────────────────────── + constexpr size_t kMaxFrames = 64; + void* frames[kMaxFrames] = {}; + SigHandlerBacktraceState state = {frames, frames + kMaxFrames}; + _Unwind_Backtrace(&SigHandlerUnwindCallback, &state); + const int frameCount = static_cast(state.current - frames); + __android_log_print(ANDROID_LOG_FATAL, "TNS.Native", + "backtrace (%d frames):", frameCount); + for (int i = 0; i < frameCount; ++i) { + LogBacktraceFrame(i, frames[i]); + } + __android_log_print(ANDROID_LOG_FATAL, "TNS.Native", + "=== end native crash ==="); + + // Existing behavior: throw so JS-side error pipeline still reports. + // Note: throwing from a signal handler is technically UB, but the existing + // runtime has relied on it for years and works under libgcc/libunwind+itanium-abi. + stringstream msg; + msg << "JNI Exception occurred (" << sigName + << ").\n=======\nCheck the 'adb logcat' for additional information about " "the error.\n=======\n"; throw NativeScriptException(msg.str()); } @@ -106,10 +211,34 @@ void Runtime::Init(JavaVM* vm, void* reserved) { // handle SIGABRT/SIGSEGV only on API level > 20 as the handling is not so // efficient in older versions if (m_androidVersion > 20) { + // Install an alternate signal stack BEFORE registering the handler so + // that we can still produce a meaningful backtrace even when the crash + // was caused by a stack overflow on the main JS thread (the original + // stack is full and would deadlock the handler otherwise). + // NDK r23+ defines SIGSTKSZ via sysconf so it isn't constexpr — use a + // fixed 64 KiB which comfortably covers Android's MINSIGSTKSZ. + constexpr size_t kAltStackSize = 64 * 1024; + static thread_local char altStackBuf[kAltStackSize]; + stack_t altStack{}; + altStack.ss_sp = altStackBuf; + altStack.ss_size = kAltStackSize; + altStack.ss_flags = 0; + sigaltstack(&altStack, nullptr); + struct sigaction action; - action.sa_handler = SIG_handler; + memset(&action, 0, sizeof(action)); + sigemptyset(&action.sa_mask); + // SA_SIGINFO enables the 3-arg handler so we get siginfo_t (fault addr + // and si_code). SA_ONSTACK lets the handler run on an alternate stack + // — important so we still produce a useful backtrace if the original + // crash was a stack overflow. + action.sa_flags = SA_SIGINFO | SA_ONSTACK; + action.sa_sigaction = SIG_handler; sigaction(SIGABRT, &action, NULL); sigaction(SIGSEGV, &action, NULL); + sigaction(SIGBUS, &action, NULL); + sigaction(SIGFPE, &action, NULL); + sigaction(SIGILL, &action, NULL); } // Set terminate handler for uncaught exceptions std::set_terminate(LogAndAbortUncaught); @@ -768,6 +897,23 @@ Isolate* Runtime::PrepareV8Runtime(const string& filesPath, globalTemplate->Set(ArgConverter::ConvertToV8String(isolate, "Worker"), workerFuncTemplate); + + // Main-thread-only HMR helper: `globalThis.__nsTerminateAllWorkers()`. + // Returns the count of workers terminated. HMR runtimes (e.g. + // @nativescript/vite) call this before re-bootstrapping the JS app so a + // cycle that re-runs a Worker-constructing scope doesn't leak a live + // worker. Workers never receive this global — a stuck worker shouldn't be + // able to take down its peers. + // + // Debug/dev only: it lets any in-process JS terminate every worker, so it + // must not ship in release. Gated on `isDebuggable` like the rest of the + // dev-global surface installed by `InitializeHmrDevGlobals` below. + if (isDebuggable) { + Local terminateAllWorkersTemplate = FunctionTemplate::New( + isolate, CallbackHandlers::TerminateAllWorkersCallback); + globalTemplate->Set(ArgConverter::ConvertToV8String(isolate, "__nsTerminateAllWorkers"), + terminateAllWorkersTemplate); + } } /* @@ -785,79 +931,29 @@ Isolate* Runtime::PrepareV8Runtime(const string& filesPath, Local context = Context::New(isolate, nullptr, globalTemplate); - auto blob_methods = R"js( - const BLOB_STORE = new Map(); - URL.createObjectURL = function (object, options = null) { - try { - if (object instanceof Blob || object instanceof File) { - const id = java.util.UUID.randomUUID().toString(); - const ret = `blob:nativescript/${id}`; - BLOB_STORE.set(ret, { - blob: object, - type: object?.type, - ext: options?.ext, - }); - return ret; - } - } catch (error) { - return null; - } - return null; - }; - URL.revokeObjectURL = function (url) { - BLOB_STORE.delete(url); - }; - const InternalAccessor = class {}; - InternalAccessor.getData = function (url) { - return BLOB_STORE.get(url); - }; - URL.InternalAccessor = InternalAccessor; - Object.defineProperty(URL.prototype, 'searchParams', { - get() { - if (this._searchParams == null) { - this._searchParams = new URLSearchParams(this.search); - Object.defineProperty(this._searchParams, '_url', { - enumerable: false, - writable: false, - value: this, - }); - this._searchParams._append = this._searchParams.append; - this._searchParams.append = function (name, value) { - this._append(name, value); - this._url.search = this.toString(); - }; - this._searchParams._delete = this._searchParams.delete; - this._searchParams.delete = function (name) { - this._delete(name); - this._url.search = this.toString(); - }; - this._searchParams._set = this._searchParams.set; - this._searchParams.set = function (name, value) { - this._set(name, value); - this._url.search = this.toString(); - }; - this._searchParams._sort = this._searchParams.sort; - this._searchParams.sort = function () { - this._sort(); - this._url.search = this.toString(); - }; - } - return this._searchParams; - }, - }); - )js"; - auto global = context->Global(); v8::Context::Scope contextScope{context}; - v8::Local script; - v8::Script::Compile(context, - ArgConverter::ConvertToV8String(isolate, blob_methods)) - .ToLocal(&script); - - v8::Local out; - script->Run(context).ToLocal(&out); + // Install URL.createObjectURL / URL.revokeObjectURL / blob registry / + // URL.searchParams accessor. The script literal lives in `URLImpl` so + // it can be shared between runtimes. + URLImpl::InstallBlobMethods(context); + + // Install HMR + dev-session JS-callable globals on the main-thread + // isolate ONLY, and ONLY in a debuggable/dev build. Workers don't need + // (and would race on) the dev-session surface — the import-map, vendor + // registry, and per-module hot data all live on the main thread. + // + // The `isDebuggable` gate is required: this surface includes + // `__nsStartDevSession` / `__nsConfigureRuntime` which mutate the + // process-wide import map and can drive module loading, so it must be + // absent from release binaries. The actual remote fetch is independently + // gated by `DevFlags::IsRemoteUrlAllowed`, but the install itself should + // not happen in release. (`isDebuggable` is the PrepareV8Runtime param.) + if (m_isMainThread && isDebuggable) { + tns::InitializeHmrDevGlobals(isolate, context); + } m_objectManager->Init(isolate); m_module.Init(isolate, callingDir); diff --git a/test-app/runtime/src/main/cpp/URLImpl.cpp b/test-app/runtime/src/main/cpp/URLImpl.cpp index a2167d692..8efaefe80 100644 --- a/test-app/runtime/src/main/cpp/URLImpl.cpp +++ b/test-app/runtime/src/main/cpp/URLImpl.cpp @@ -9,6 +9,95 @@ using namespace ada; URLImpl::URLImpl(url_aggregator url) : url_(url) {} +// Install URL.createObjectURL / URL.revokeObjectURL / URL.InternalAccessor + +// URL.prototype.searchParams accessor onto the realm's URL constructor. +// +// Blob IDs use `java.util.UUID.randomUUID().toString()` (lower-case +// RFC-4122 v4), producing `blob:nativescript/` keys. +void URLImpl::InstallBlobMethods(v8::Local context) { + v8::Isolate* isolate = context->GetIsolate(); + // URL.createObjectURL/revokeObjectURL and blob URL registry + // Blob URLs have the format: blob:/ + // We use blob:nativescript/ as NativeScript's origin identifier + auto blob_methods = R"js( + const BLOB_STORE = new Map(); + URL.createObjectURL = function (object, options = null) { + try { + if (object instanceof Blob || object instanceof File) { + const id = java.util.UUID.randomUUID().toString(); + const ret = `blob:nativescript/${id}`; + BLOB_STORE.set(ret, { + blob: object, + type: object?.type, + ext: options?.ext, + }); + return ret; + } + } catch (error) { + return null; + } + return null; + }; + URL.revokeObjectURL = function (url) { + BLOB_STORE.delete(url); + }; + const InternalAccessor = class {}; + InternalAccessor.getData = function (url) { + return BLOB_STORE.get(url); + }; + // Get the text content directly from a blob URL (for HMR) + InternalAccessor.getText = async function (url) { + const data = BLOB_STORE.get(url); + if (!data || !data.blob) return null; + return await data.blob.text(); + }; + URL.InternalAccessor = InternalAccessor; + Object.defineProperty(URL.prototype, 'searchParams', { + get() { + if (this._searchParams == null) { + this._searchParams = new URLSearchParams(this.search); + Object.defineProperty(this._searchParams, '_url', { + enumerable: false, + writable: false, + value: this, + }); + this._searchParams._append = this._searchParams.append; + this._searchParams.append = function (name, value) { + this._append(name, value); + this._url.search = this.toString(); + }; + this._searchParams._delete = this._searchParams.delete; + this._searchParams.delete = function (name) { + this._delete(name); + this._url.search = this.toString(); + }; + this._searchParams._set = this._searchParams.set; + this._searchParams.set = function (name, value) { + this._set(name, value); + this._url.search = this.toString(); + }; + this._searchParams._sort = this._searchParams.sort; + this._searchParams.sort = function () { + this._sort(); + this._url.search = this.toString(); + }; + } + return this._searchParams; + }, + }); + )js"; + + v8::Local script; + auto compiled = v8::Script::Compile( + context, ArgConverter::ConvertToV8String(isolate, blob_methods)) + .ToLocal(&script); + + if (compiled) { + v8::Local outVal; + (void)script->Run(context).ToLocal(&outVal); + } +} + URLImpl *URLImpl::GetPointer(v8::Local object) { auto ptr = object->GetAlignedPointerFromInternalField(0); if (ptr == nullptr) { diff --git a/test-app/runtime/src/main/cpp/URLImpl.h b/test-app/runtime/src/main/cpp/URLImpl.h index e36002ec2..aadf939f5 100644 --- a/test-app/runtime/src/main/cpp/URLImpl.h +++ b/test-app/runtime/src/main/cpp/URLImpl.h @@ -21,6 +21,15 @@ namespace tns { static v8::Local GetCtor(v8::Isolate *isolate); + // Compiles and runs the JS polyfill that installs + // `URL.createObjectURL` / `URL.revokeObjectURL`, the in-process blob + // registry (`URL.InternalAccessor`) used by the HMR loader, and the + // `URL.prototype.searchParams` accessor. Must be called once per + // realm AFTER `URL` and `URLSearchParams` constructors are installed. + // Behavior is bit-for-bit identical to the previously inlined script + // literal in `Runtime::Init`. + static void InstallBlobMethods(v8::Local context); + static void Ctor(const v8::FunctionCallbackInfo &args); diff --git a/test-app/runtime/src/main/cpp/WorkerWrapper.cpp b/test-app/runtime/src/main/cpp/WorkerWrapper.cpp index d23c3d30e..ba2e13d2f 100644 --- a/test-app/runtime/src/main/cpp/WorkerWrapper.cpp +++ b/test-app/runtime/src/main/cpp/WorkerWrapper.cpp @@ -529,7 +529,7 @@ void WorkerWrapper::ClearWorkerOnParent(int workerId) { } } -void WorkerWrapper::TerminateChildren(Isolate* parentIsolate) { +int WorkerWrapper::TerminateChildren(Isolate* parentIsolate) { std::vector> children; { std::lock_guard lock(registryMutex_); @@ -547,6 +547,8 @@ void WorkerWrapper::TerminateChildren(Isolate* parentIsolate) { child->Terminate(); ClearWorkerOnParent(child->workerId_); } + + return static_cast(children.size()); } WorkerWrapper* WorkerWrapper::FromIsolate(Isolate* isolate) { diff --git a/test-app/runtime/src/main/cpp/WorkerWrapper.h b/test-app/runtime/src/main/cpp/WorkerWrapper.h index 464145d2f..5a0e2f863 100644 --- a/test-app/runtime/src/main/cpp/WorkerWrapper.h +++ b/test-app/runtime/src/main/cpp/WorkerWrapper.h @@ -113,8 +113,9 @@ class WorkerWrapper : public std::enable_shared_from_this { * Must run on the parent's thread, before the parent isolate is disposed * (the children's Worker object persistents live in that isolate). * Cascades: each child terminates its own children during shutdown. + * Returns the number of direct child workers that were torn down. */ - static void TerminateChildren(v8::Isolate* parentIsolate); + static int TerminateChildren(v8::Isolate* parentIsolate); /* * Resolves the wrapper of a worker isolate via its isolate data slot. diff --git a/test-app/runtime/src/main/java/com/tns/AppConfig.java b/test-app/runtime/src/main/java/com/tns/AppConfig.java index c163cbd54..095f5d1f2 100644 --- a/test-app/runtime/src/main/java/com/tns/AppConfig.java +++ b/test-app/runtime/src/main/java/com/tns/AppConfig.java @@ -24,7 +24,9 @@ protected enum KnownKeys { DiscardUncaughtJsExceptions("discardUncaughtJsExceptions", false), EnableLineBreakpoins("enableLineBreakpoints", false), EnableMultithreadedJavascript("enableMultithreadedJavascript", false), - LogScriptLoading("logScriptLoading", false); + LogScriptLoading("logScriptLoading", false), + HttpModulePrefetch("httpModulePrefetch", false), + HttpFetchUrlLog("httpFetchUrlLog", false); private final String name; private final Object defaultValue; @@ -86,6 +88,12 @@ public AppConfig(File appDir) { if (rootObject.has(KnownKeys.LogScriptLoading.getName())) { values[KnownKeys.LogScriptLoading.ordinal()] = rootObject.getBoolean(KnownKeys.LogScriptLoading.getName()); } + if (rootObject.has(KnownKeys.HttpModulePrefetch.getName())) { + values[KnownKeys.HttpModulePrefetch.ordinal()] = rootObject.getBoolean(KnownKeys.HttpModulePrefetch.getName()); + } + if (rootObject.has(KnownKeys.HttpFetchUrlLog.getName())) { + values[KnownKeys.HttpFetchUrlLog.ordinal()] = rootObject.getBoolean(KnownKeys.HttpFetchUrlLog.getName()); + } if (rootObject.has(KnownKeys.DiscardUncaughtJsExceptions.getName())) { values[KnownKeys.DiscardUncaughtJsExceptions.ordinal()] = rootObject.getBoolean(KnownKeys.DiscardUncaughtJsExceptions.getName()); } @@ -206,6 +214,16 @@ public boolean getLogScriptLoading() { return (v instanceof Boolean) ? ((Boolean)v).booleanValue() : false; } + public boolean getHttpModulePrefetch() { + Object v = values[KnownKeys.HttpModulePrefetch.ordinal()]; + return (v instanceof Boolean) ? ((Boolean)v).booleanValue() : false; + } + + public boolean getHttpFetchUrlLog() { + Object v = values[KnownKeys.HttpFetchUrlLog.ordinal()]; + return (v instanceof Boolean) ? ((Boolean)v).booleanValue() : false; + } + // Security conf /** diff --git a/test-app/runtime/src/main/java/com/tns/ClassResolver.java b/test-app/runtime/src/main/java/com/tns/ClassResolver.java index 0dba4c64a..058671a64 100644 --- a/test-app/runtime/src/main/java/com/tns/ClassResolver.java +++ b/test-app/runtime/src/main/java/com/tns/ClassResolver.java @@ -1,6 +1,7 @@ package com.tns; import com.tns.system.classes.loading.ClassStorageService; +import com.tns.system.classes.loading.LookedUpClassNotFound; import java.io.IOException; @@ -26,7 +27,35 @@ Class resolveClass(String baseClassName, String fullClassName, DexFactory dex } if (clazz == null) { - clazz = classStorageService.retrieveClass(className); + try { + clazz = classStorageService.retrieveClass(className); + } catch (LookedUpClassNotFound notFound) { + // HMR / dev fallback. The Static Binding Generator pre-generates + // a Java stub for every `.extend('com.tns.Name', { ... })` call + // it sees at build time, and production bakes those stubs into + // the APK dex. In Vite HMR mode the bundle is just the boot + // loader and modules are fetched over HTTP at runtime, so the + // SBG never sees those `.extend(...)` calls and the class is + // missing from the dex. + // + // With a baseClassName, JS is extending a known Java type, so + // route through `DexFactory.resolveClass` (the same path as + // `com.tns.gen.*` bindings) to generate + load a dex at runtime. + // Lookups without a baseClassName are plain `Class.forName`-style + // requests and must still fail loudly. + // + // Gated on `isDebuggable()`: runtime dex generation and + // parent-classloader injection are only needed in dev. In + // release every stub is baked in, so a miss is a genuinely + // missing class that should fail rather than silently mutate + // the app classloader. + if (!canonicalBaseClassName.isEmpty() && com.tns.Runtime.isDebuggable()) { + clazz = dexFactory.resolveClass(canonicalBaseClassName, name, className, methodOverrides, implementedInterfaces, isInterface); + } + if (clazz == null) { + throw notFound; + } + } } return clazz; diff --git a/test-app/runtime/src/main/java/com/tns/DexFactory.java b/test-app/runtime/src/main/java/com/tns/DexFactory.java index 56b37462e..1fdf572af 100644 --- a/test-app/runtime/src/main/java/com/tns/DexFactory.java +++ b/test-app/runtime/src/main/java/com/tns/DexFactory.java @@ -35,6 +35,26 @@ public class DexFactory { private static final String COM_TNS_GEN_PREFIX = "com.tns.gen."; + // Generated proxy classes always live under names where the original + // `$` separators in inner-class qualifiers have been replaced with + // `_`. The proxy generator writes the dex with `_`, so any load via + // `classLoader.loadClass(...)` must use the same form. This helper + // is the single canonical entry point for that normalization so + // every load site agrees. + // + // Scoped strictly to the `com.tns.gen.` prefix; unrelated + // inner-class lookups (e.g. `java.util.HashMap$Entry`) are returned + // unchanged so JVM inner-class syntax keeps working. + private static String normalizeProxyClassName(String name) { + if (name == null) { + return null; + } + if (!name.startsWith(COM_TNS_GEN_PREFIX) || name.indexOf('$') < 0) { + return name; + } + return name.replace('$', '_'); + } + private final Logger logger; private final File dexDir; private final File odexDir; @@ -121,9 +141,19 @@ public Class resolveClass(String baseClassName, String name, String className String desiredDexClassName = this.getClassToProxyName(fullClassName); // when interfaces are extended as classes, we still want to preserve - // just the interface name without the extra file, line, column information + // just the interface name without the extra file, line, column information. + // + // The loadable proxy class name uses the `_`-normalized form: the proxy + // is written into the dex with `_`, while `classToProxy` keeps `$` so + // `Class.forName(classToProxy)` (in `generateDex`) can resolve the base + // type via JVM inner-class syntax. Without normalizing here, a + // `$`-containing base (e.g. unnamed + // `.extend(android.app.Application.ActivityLifecycleCallbacks, ...)`) + // fails `loadClass` with ClassNotFoundException even though the dex is + // present. Surfaces in HMR / dev; production uses an SBG-generated + // `@JavaProxy(...)` sibling and bypasses this path. if (!baseClassName.isEmpty() && isInterface) { - fullClassName = COM_TNS_GEN_PREFIX + classToProxy; + fullClassName = normalizeProxyClassName(COM_TNS_GEN_PREFIX + classToProxy); } File dexFile = this.getDexFile(desiredDexClassName); @@ -204,6 +234,29 @@ public Class findClass(String className) throws ClassNotFoundException { return existingClass; } + // Generated proxy classes live under `_`-normalized names (see + // `normalizeProxyClassName`). If JNI hands us a `$`-containing + // `com.tns.gen.*` lookup (a metadata dispatch that passed a `$`-bearing + // base name straight through), try the normalized sibling — both the + // `injectedDexClasses` cache and `loadClass` — before falling back to + // the raw form. Scoped to the `com.tns.gen.` prefix so unrelated + // inner-class lookups (e.g. `java.util.HashMap$Entry`) still resolve via + // JVM inner-class syntax at the tail of this method. + String normalizedName = normalizeProxyClassName(canonicalName); + if (!normalizedName.equals(canonicalName)) { + existingClass = this.injectedDexClasses.get(normalizedName); + if (existingClass != null) { + return existingClass; + } + try { + return classLoader.loadClass(normalizedName); + } catch (ClassNotFoundException ignored) { + // fall through to the raw canonical lookup so the original + // failure (not a noise-from-the-fallback failure) is what + // propagates to the caller. + } + } + return classLoader.loadClass(canonicalName); } diff --git a/test-app/runtime/src/main/java/com/tns/Runtime.java b/test-app/runtime/src/main/java/com/tns/Runtime.java index bb8f3db19..228cec272 100644 --- a/test-app/runtime/src/main/java/com/tns/Runtime.java +++ b/test-app/runtime/src/main/java/com/tns/Runtime.java @@ -291,6 +291,33 @@ public static boolean getLogScriptLoadingEnabled() { } return false; } + + // Expose httpModulePrefetch flag for native code without re-reading package.json. + // Default OFF: opt in via package.json "httpModulePrefetch": true. + public static boolean getHttpModulePrefetchEnabled() { + Runtime runtime = com.tns.Runtime.getCurrentRuntime(); + if (runtime != null && runtime.config != null && runtime.config.appConfig != null) { + return runtime.config.appConfig.getHttpModulePrefetch(); + } + if (staticConfiguration != null && staticConfiguration.appConfig != null) { + return staticConfiguration.appConfig.getHttpModulePrefetch(); + } + return false; + } + + // Expose httpFetchUrlLog flag for native code without re-reading package.json. + // Default OFF (per-fetch log volume is high). Opt in via package.json + // "httpFetchUrlLog": true to diagnose HTTP module loader behavior. + public static boolean getHttpFetchUrlLogEnabled() { + Runtime runtime = com.tns.Runtime.getCurrentRuntime(); + if (runtime != null && runtime.config != null && runtime.config.appConfig != null) { + return runtime.config.appConfig.getHttpFetchUrlLog(); + } + if (staticConfiguration != null && staticConfiguration.appConfig != null) { + return staticConfiguration.appConfig.getHttpFetchUrlLog(); + } + return false; + } // Security config From 708fecdd9bfc996bd18d38f33ea8f117d8345516 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Mon, 15 Jun 2026 22:25:17 -0700 Subject: [PATCH 02/11] feat: HMR robustness and additional tests --- test-app/app/src/main/assets/app/mainpage.js | 1 + .../assets/app/tests/testHttpCanonicalKey.mjs | 52 ++++++ test-app/runtime/src/main/cpp/DevFlags.cpp | 29 +++- test-app/runtime/src/main/cpp/HMRSupport.cpp | 151 +++++++++--------- test-app/runtime/src/main/cpp/HMRSupport.h | 7 +- .../src/main/cpp/ModuleInternalCallbacks.cpp | 63 ++------ .../src/main/cpp/ModuleInternalCallbacks.h | 20 ++- .../src/main/java/com/tns/Runtime.java | 27 +++- 8 files changed, 207 insertions(+), 143 deletions(-) create mode 100644 test-app/app/src/main/assets/app/tests/testHttpCanonicalKey.mjs diff --git a/test-app/app/src/main/assets/app/mainpage.js b/test-app/app/src/main/assets/app/mainpage.js index b6cf77fd3..2142b3173 100644 --- a/test-app/app/src/main/assets/app/mainpage.js +++ b/test-app/app/src/main/assets/app/mainpage.js @@ -78,4 +78,5 @@ require("./tests/testConcurrentAccess"); require("./tests/testESModules.mjs"); require("./tests/testHmrHotDataExt.mjs"); +require("./tests/testHttpCanonicalKey.mjs"); require("./tests/testNodeBuiltinsAndOptionalModules.mjs"); diff --git a/test-app/app/src/main/assets/app/tests/testHttpCanonicalKey.mjs b/test-app/app/src/main/assets/app/tests/testHttpCanonicalKey.mjs new file mode 100644 index 000000000..c4b092cc7 --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testHttpCanonicalKey.mjs @@ -0,0 +1,52 @@ +// HTTP canonical-key identity tests. +// +// Pins the behavior of the native CanonicalizeHttpUrlKey (the loader/registry +// key) via the debug-only __nsCanonicalizeHttpUrlKey diagnostic global. Pure +// string logic — no dev server required. Android does NOT collapse the +// __ns_boot__/__ns_hmr__ virtual prefixes here (that is canonicalHotKey's job), +// which these specs assert explicitly. + +describe("HTTP canonical key", function () { + function canon(url) { + return globalThis.__nsCanonicalizeHttpUrlKey(url); + } + + it("is available in dev builds", function () { + if (typeof globalThis.__nsCanonicalizeHttpUrlKey !== "function") { + pending("__nsCanonicalizeHttpUrlKey not available (release build?)"); + return; + } + expect(typeof globalThis.__nsCanonicalizeHttpUrlKey).toBe("function"); + }); + + it("drops the fragment and unwraps file://http wrappers", function () { + if (typeof globalThis.__nsCanonicalizeHttpUrlKey !== "function") { pending("release"); return; } + expect(canon("http://h/ns/m/foo.js#frag")).toBe("http://h/ns/m/foo.js"); + expect(canon("file://http://h/x.js")).toBe("http://h/x.js"); + }); + + it("normalizes versioned bridge endpoints but not deeper paths", function () { + if (typeof globalThis.__nsCanonicalizeHttpUrlKey !== "function") { pending("release"); return; } + expect(canon("http://h/ns/rt/42")).toBe("http://h/ns/rt"); + expect(canon("http://h/ns/core/13")).toBe("http://h/ns/core"); + expect(canon("http://h/ns/rt/42/x.js")).toBe("http://h/ns/rt/42/x.js"); + }); + + it("does NOT collapse __ns_hmr__/__ns_boot__ prefixes (Android loader key)", function () { + if (typeof globalThis.__nsCanonicalizeHttpUrlKey !== "function") { pending("release"); return; } + expect(canon("http://h/ns/m/__ns_hmr__/v7/foo.js")) + .toBe("http://h/ns/m/__ns_hmr__/v7/foo.js"); + }); + + it("strips ?import and sorts remaining query params", function () { + if (typeof globalThis.__nsCanonicalizeHttpUrlKey !== "function") { pending("release"); return; } + expect(canon("http://h/a?import=1&b=2&a=3")).toBe("http://h/a?a=3&b=2"); + expect(canon("http://h/a?b=2&a=1")).toBe("http://h/a?a=1&b=2"); + expect(canon("http://h/ns/core?import=1")).toBe("http://h/ns/core"); + }); + + it("leaves a non-http(s) specifier unchanged", function () { + if (typeof globalThis.__nsCanonicalizeHttpUrlKey !== "function") { pending("release"); return; } + expect(canon("~/local/foo.js")).toBe("~/local/foo.js"); + }); +}); diff --git a/test-app/runtime/src/main/cpp/DevFlags.cpp b/test-app/runtime/src/main/cpp/DevFlags.cpp index c826de43d..eefa68f82 100644 --- a/test-app/runtime/src/main/cpp/DevFlags.cpp +++ b/test-app/runtime/src/main/cpp/DevFlags.cpp @@ -106,10 +106,25 @@ static bool s_allowRemoteModules = false; static std::vector s_remoteModuleAllowlist; static bool s_isDebuggable = false; -// Helper to check if a URL starts with a given prefix -static bool UrlStartsWith(const std::string& url, const std::string& prefix) { - if (prefix.size() > url.size()) return false; - return url.compare(0, prefix.size(), prefix) == 0; +// Returns true when `url` is covered by allowlist `entry`, matching only on +// URL component boundaries so lookalike-host and lookalike-port values are +// refused: an entry of "https://cdn.example.com" does NOT authorize +// "https://cdn.example.com.attacker.com/x.js" or +// "https://cdn.example.com:9999/x.js". The character after the matched entry +// text must be a path/query/fragment boundary ('/', '?', '#'), or the URL must +// end exactly at the entry, or the entry must already end in '/'. +// +// Deny-by-default: an entry without an explicit port does not match a URL +// that adds one. To allow a specific port, include it in the entry. +static bool RemoteUrlMatchesAllowlistEntry(const std::string& url, + const std::string& entry) { + if (entry.empty()) return false; + if (url.size() < entry.size()) return false; + if (url.compare(0, entry.size(), entry) != 0) return false; + if (url.size() == entry.size()) return true; // exact match + if (entry.back() == '/') return true; // entry ended at a boundary + const char next = url[entry.size()]; + return next == '/' || next == '?' || next == '#'; } void InitializeSecurityConfig() { @@ -195,9 +210,9 @@ bool IsRemoteUrlAllowed(const std::string& url) { return true; } - // Check if URL matches any allowlist prefix - for (const std::string& prefix : s_remoteModuleAllowlist) { - if (UrlStartsWith(url, prefix)) { + // Check if URL matches any allowlist entry on a component boundary + for (const std::string& entry : s_remoteModuleAllowlist) { + if (RemoteUrlMatchesAllowlistEntry(url, entry)) { return true; } } diff --git a/test-app/runtime/src/main/cpp/HMRSupport.cpp b/test-app/runtime/src/main/cpp/HMRSupport.cpp index 61f6f5eea..8e37e2096 100644 --- a/test-app/runtime/src/main/cpp/HMRSupport.cpp +++ b/test-app/runtime/src/main/cpp/HMRSupport.cpp @@ -31,7 +31,7 @@ namespace tns { // definitions live in ModuleInternalCallbacks.cpp; this header-style // forward block lets HMRSupport.cpp call them without pulling the // resolver header in (avoids a circular include). -void SetImportMap(const std::string& json); +void SetImportMapEntries(const std::vector>& entries); void SetVolatilePatterns(const std::vector& patterns); std::vector GetLoadedModuleUrls(); @@ -178,10 +178,53 @@ static bool IsSupportedDevSessionPlatform(const std::string& platform) { return platform == "android"; } +// Parse an import-map value (a JSON string OR a JS object of shape +// `{ imports: { "": "", ... } }`) into flat (key → URL) entries using +// V8's own JSON/object model. Returns true if it found an `imports` object +// (even if empty); false if the value is unusable. Only flat string key→URL +// mappings are honored; non-string import values are skipped. +static bool ReadImportMapEntries(v8::Isolate* isolate, + v8::Local context, + v8::Local importMapValue, + std::vector>* out) { + v8::Local mapVal = importMapValue; + if (mapVal->IsString()) { + v8::Local parsed; + if (!v8::JSON::Parse(context, mapVal.As()).ToLocal(&parsed)) { + return false; + } + mapVal = parsed; + } + if (!mapVal->IsObject()) return false; + v8::Local mapObj = mapVal.As(); + + v8::Local importsVal; + if (!mapObj->Get(context, ToV8String(isolate, "imports")).ToLocal(&importsVal) || + !importsVal->IsObject()) { + return false; + } + v8::Local imports = importsVal.As(); + v8::Local keys; + if (!imports->GetOwnPropertyNames(context).ToLocal(&keys)) return false; + + for (uint32_t i = 0; i < keys->Length(); ++i) { + v8::Local keyVal; + if (!keys->Get(context, i).ToLocal(&keyVal)) continue; + v8::Local valVal; + if (!imports->Get(context, keyVal).ToLocal(&valVal) || !valVal->IsString()) continue; + v8::String::Utf8Value keyUtf8(isolate, keyVal); + v8::String::Utf8Value valUtf8(isolate, valVal); + if (*keyUtf8 && *valUtf8) { + out->emplace_back(std::string(*keyUtf8), std::string(*valUtf8)); + } + } + return true; +} + // Apply the v8::Object payload of `__nsConfigureDevRuntime`: re-validate the -// `importMap` shape and serialize it back to JSON for `SetImportMap`. Parsing -// runs entirely in V8 (via `ConfigureDevRuntimeCallback`), so this is a thin -// wrapper over that shared validation. +// `importMap` shape and read its entries via `ReadImportMapEntries` (V8-based +// parsing) into the process-wide map. Mirrors the import-map handling in +// `ConfigureDevRuntimeCallback`. static bool ApplyDevRuntimeConfigObject(v8::Isolate* isolate, v8::Local context, v8::Local payload, @@ -202,49 +245,20 @@ static bool ApplyDevRuntimeConfigObject(v8::Isolate* isolate, return false; } - // Use JSON.stringify on the importMap object — keeps the on-disk format - // identical to what `__nsConfigureRuntime` already accepts. - v8::Local jsonObj; - v8::Local globalJson; - if (!context->Global()->Get(context, ToV8String(isolate, "JSON")).ToLocal(&globalJson) || - !globalJson->IsObject()) { - if (errorMessage != nullptr) { - *errorMessage = "[__nsStartDevSession] JSON global unavailable"; - } - return false; - } - jsonObj = globalJson.As(); - - v8::Local stringifyFnVal; - if (!jsonObj->Get(context, ToV8String(isolate, "stringify")).ToLocal(&stringifyFnVal) || - !stringifyFnVal->IsFunction()) { - if (errorMessage != nullptr) { - *errorMessage = "[__nsStartDevSession] JSON.stringify unavailable"; - } - return false; - } - - v8::Local stringifyFn = stringifyFnVal.As(); - v8::Local args[] = {importMapValue}; - v8::MaybeLocal maybeJson = stringifyFn->Call(context, jsonObj, 1, args); - v8::Local jsonVal; - if (!maybeJson.ToLocal(&jsonVal) || !jsonVal->IsString()) { + std::vector> importEntries; + if (!ReadImportMapEntries(isolate, context, importMapValue, &importEntries)) { if (errorMessage != nullptr) { - *errorMessage = "[__nsStartDevSession] failed to serialize importMap"; + *errorMessage = "[__nsStartDevSession] failed to read importMap"; } return false; } - - v8::String::Utf8Value jsonUtf8(isolate, jsonVal); - std::string importMapJson = *jsonUtf8 ? *jsonUtf8 : ""; - if (importMapJson.empty()) { + if (importEntries.empty()) { if (errorMessage != nullptr) { *errorMessage = "[__nsStartDevSession] runtime config importMap was empty"; } return false; } - - SetImportMap(importMapJson); + SetImportMapEntries(importEntries); std::vector patterns; v8::Local volatilePatternsValue; @@ -447,14 +461,10 @@ bool ApplyDevRuntimeConfigFromUrl(const std::string& url, return true; } -// Native-side mirror of `__NS_HMR_BOOT_COMPLETE__`. Read by the -// runloop pump in `MaybePumpJSThreadDuringBoot` so its gate is a -// single relaxed atomic load on the HMR-time hot path. -static std::atomic g_devSessionBootComplete{false}; - -static inline bool IsDevSessionBootComplete() { - return g_devSessionBootComplete.load(std::memory_order_relaxed); -} +// The live "dev-session boot complete" signal is the JS global +// __NS_HMR_BOOT_COMPLETE__, set by ApplyDevSessionGlobals / +// SetDevSessionBootComplete below. (There is no native runloop pump on +// Android — the main NativeScript isolate runs JS on the UI thread.) void ApplyDevSessionGlobals(v8::Isolate* isolate, v8::Local context, @@ -464,7 +474,6 @@ void ApplyDevSessionGlobals(v8::Isolate* isolate, SetBooleanGlobal(isolate, context, "__NS_HMR_BOOT_COMPLETE__", false); SetBooleanGlobal(isolate, context, "__NS_HMR_CLIENT_ACTIVE__", false); SetBooleanGlobal(isolate, context, "__NS_HMR_BROWSER_RUNTIME_CLIENT_ACTIVE__", false); - g_devSessionBootComplete.store(false, std::memory_order_relaxed); if (IsScriptLoadingLogEnabled()) { DEBUG_WRITE("[dev-session] globals applied session=%s origin=%s ws=%s bootComplete=false", session.sessionId.c_str(), session.origin.c_str(), @@ -476,7 +485,6 @@ void SetDevSessionBootComplete(v8::Isolate* isolate, v8::Local context, bool value) { SetBooleanGlobal(isolate, context, "__NS_HMR_BOOT_COMPLETE__", value); - g_devSessionBootComplete.store(value, std::memory_order_relaxed); if (IsScriptLoadingLogEnabled()) { DEBUG_WRITE("[dev-session] __NS_HMR_BOOT_COMPLETE__=%s", value ? "true" : "false"); @@ -2439,32 +2447,12 @@ void ConfigureDevRuntimeCallback(const v8::FunctionCallbackInfo& info v8::Local importMapVal; if (config->Get(ctx, ToV8String(isolate, "importMap")).ToLocal(&importMapVal) && !importMapVal->IsUndefined() && !importMapVal->IsNull()) { - std::string jsonStr; - if (importMapVal->IsString()) { - v8::String::Utf8Value utf8(isolate, importMapVal); - if (*utf8) jsonStr = *utf8; - } else if (importMapVal->IsObject()) { - v8::Local jsonGlobalVal; - if (ctx->Global()->Get(ctx, ToV8String(isolate, "JSON")).ToLocal(&jsonGlobalVal) && - jsonGlobalVal->IsObject()) { - v8::Local jsonObj = jsonGlobalVal.As(); - v8::Local stringifyVal; - if (jsonObj->Get(ctx, ToV8String(isolate, "stringify")).ToLocal(&stringifyVal) && - stringifyVal->IsFunction()) { - v8::Local stringify = stringifyVal.As(); - v8::Local args[] = {importMapVal}; - v8::Local result; - if (stringify->Call(ctx, jsonObj, 1, args).ToLocal(&result) && result->IsString()) { - v8::String::Utf8Value utf8(isolate, result); - if (*utf8) jsonStr = *utf8; - } - } - } - } - if (!jsonStr.empty()) { - SetImportMap(jsonStr); + std::vector> importEntries; + if (ReadImportMapEntries(isolate, ctx, importMapVal, &importEntries) && + !importEntries.empty()) { + SetImportMapEntries(importEntries); if (logScriptLoading) { - DEBUG_WRITE("[__nsConfigureRuntime] import map set (%zu bytes)", jsonStr.size()); + DEBUG_WRITE("[__nsConfigureRuntime] import map set (%zu entries)", importEntries.size()); } } } @@ -2909,6 +2897,22 @@ void ApplyStyleUpdateCallback(const v8::FunctionCallbackInfo& info) { } } +// Debug-only diagnostic: expose CanonicalizeHttpUrlKey to JS so the test harness +// can pin its identity behavior. Not part of the @nativescript/vite client API. +// The whole installer is gated on isDebuggable at the call site, so this never +// ships in release. +void CanonicalizeHttpUrlKeyCallback(const v8::FunctionCallbackInfo& info) { + v8::Isolate* isolate = info.GetIsolate(); + v8::HandleScope scope(isolate); + if (info.Length() < 1 || !info[0]->IsString()) { + info.GetReturnValue().SetEmptyString(); + return; + } + v8::String::Utf8Value u(isolate, info[0]); + std::string key = CanonicalizeHttpUrlKey(*u ? std::string(*u) : std::string()); + info.GetReturnValue().Set(ToV8String(isolate, key.c_str())); +} + void GetLoadedModuleUrlsCallback(const v8::FunctionCallbackInfo& info) { v8::Isolate* isolate = info.GetIsolate(); v8::HandleScope scope(isolate); @@ -2962,6 +2966,7 @@ void InitializeHmrDevGlobals(v8::Isolate* isolate, v8::Local contex InstallGlobalFunction(isolate, context, "__nsReloadDevApp", ReloadDevAppCallback); InstallGlobalFunction(isolate, context, "__nsApplyStyleUpdate", ApplyStyleUpdateCallback); InstallGlobalFunction(isolate, context, "__nsGetLoadedModuleUrls", GetLoadedModuleUrlsCallback); + InstallGlobalFunction(isolate, context, "__nsCanonicalizeHttpUrlKey", CanonicalizeHttpUrlKeyCallback); } void CleanupHMRGlobals() { diff --git a/test-app/runtime/src/main/cpp/HMRSupport.h b/test-app/runtime/src/main/cpp/HMRSupport.h index e3ed44667..3ea83f864 100644 --- a/test-app/runtime/src/main/cpp/HMRSupport.h +++ b/test-app/runtime/src/main/cpp/HMRSupport.h @@ -167,9 +167,10 @@ void ClearHttpModulePrefetchCache(); // synchronous network turn so the caller can pump its own runloop (e.g. the // JS-thread runloop so a placeholder UI can repaint during cold-boot). // -// Default: a built-in pump that no-ops outside the JS thread / after the -// dev-session boot completes (see `MaybePumpJSThreadDuringBoot` in -// HMRSupport.cpp). +// Default: a no-op (`NoopHttpFetchYield`). Android's main NativeScript +// isolate runs JS on the UI thread, so there is no separate JS-thread +// runloop to pump here; a host that drives its own loop can install a real +// pump (e.g. one calling ALooper_pollOnce(0)) via this hook. // // Pass `nullptr` to disable any yielding (used by hosts that drive their own // run loop or by tests that want bit-for-bit deterministic fetch timing). diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp index 448080352..fd4da4dbf 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp @@ -250,9 +250,12 @@ static bool HasUrlScheme(const std::string& spec) { return true; } -// Cache-bypass check. Used by HttpFetchText's fast-path lookup to decide -// whether to ignore a hit. Patterns are simple substring matches, populated -// by Vite via `__nsConfigureRuntime({ volatilePatterns: [...] })`. +// Matches `url` against the `volatilePatterns` configured by Vite via +// `__nsConfigureRuntime({ volatilePatterns: [...] })` (substring match). +// Android's HTTP loader does not consult this: it enforces volatility +// structurally — a consume-once prefetch read plus eviction on HMR +// invalidation (see HttpFetchText in HMRSupport.cpp). It is available for a +// read-gated cache policy, which the iOS loader uses. static bool IsVolatileUrl(const std::string& url) { std::lock_guard lock(g_volatilePatternsMutex); for (const auto& pat : g_volatilePatterns) { @@ -650,56 +653,16 @@ static v8::MaybeLocal ResolveFromVendorRegistry(v8::Isolate* isolate return m; } -// Public: register import-map JSON blob. The full JSON shape is -// `{"imports": {"": "", ...}}`. The flat shape is walked -// directly so this module does not need to depend on a JSON parser. -void SetImportMap(const std::string& json) { +// Public: replace the import map with parsed (key → URL) entries. The dev server's +// import-map JSON is parsed by V8 in the caller (HMRSupport.cpp); only the flat +// `imports` table reaches here. +void SetImportMapEntries(const std::vector>& entries) { std::lock_guard lock(g_importMapMutex); g_importMap.clear(); - size_t importsPos = json.find("\"imports\""); - if (importsPos == std::string::npos) return; - size_t braceOpen = json.find('{', importsPos + 9); - if (braceOpen == std::string::npos) return; - // Find matching close brace, accounting for nested values (we still only - // support flat key->string, but the body may contain escaped quotes). - int depth = 1; - size_t i = braceOpen + 1; - size_t braceClose = std::string::npos; - bool inString = false; - while (i < json.size()) { - char c = json[i]; - if (inString) { - if (c == '\\' && i + 1 < json.size()) { i += 2; continue; } - if (c == '"') inString = false; - } else { - if (c == '"') inString = true; - else if (c == '{') ++depth; - else if (c == '}') { - --depth; - if (depth == 0) { braceClose = i; break; } - } + for (const auto& kv : entries) { + if (!kv.first.empty()) { + g_importMap[kv.first] = kv.second; } - ++i; - } - if (braceClose == std::string::npos) return; - - std::string inner = json.substr(braceOpen + 1, braceClose - braceOpen - 1); - size_t pos = 0; - while (pos < inner.size()) { - size_t keyStart = inner.find('"', pos); - if (keyStart == std::string::npos) break; - size_t keyEnd = inner.find('"', keyStart + 1); - if (keyEnd == std::string::npos) break; - std::string key = inner.substr(keyStart + 1, keyEnd - keyStart - 1); - - size_t valStart = inner.find('"', keyEnd + 1); - if (valStart == std::string::npos) break; - size_t valEnd = inner.find('"', valStart + 1); - if (valEnd == std::string::npos) break; - std::string val = inner.substr(valStart + 1, valEnd - valStart - 1); - - g_importMap[key] = val; - pos = valEnd + 1; } if (IsScriptLoadingLogEnabled()) { DEBUG_WRITE("[import-map] loaded %lu entries", (unsigned long)g_importMap.size()); diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h index 76ec986d6..bc3e3a3cf 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h @@ -19,14 +19,18 @@ extern thread_local std::unordered_map>& g_m // Import-map and volatile-pattern configuration. // -// `SetImportMap` accepts the dev server's JSON import-map blob (parsed and -// merged into the process-wide bare-specifier → URL map used by -// `ResolveModuleCallback`). `SetVolatilePatterns` accepts a list of URL -// substrings that should always re-fetch (never serve from the -// speculative-prefetch cache). Both are applied via `__nsConfigureRuntime` -// / `__nsConfigureDevRuntime` at session start and again at every HMR -// graph version bump. -void SetImportMap(const std::string& json); +// `SetImportMapEntries` populates the process-wide bare-specifier → URL map +// used by `ResolveModuleCallback`; the dev server's import-map JSON is parsed +// at the V8 layer by the callers, which pass the flat entries here. +// `SetVolatilePatterns` accepts a list of URL substrings that should always +// re-fetch (never serve from the speculative-prefetch cache). Both are applied +// via `__nsConfigureRuntime` / `__nsConfigureDevRuntime` at session start and +// again at every HMR graph version bump. + +// Set the process-wide import map from the given flat (bare-specifier → URL) +// entries. The callers hold the parsed import-map object and extract its +// `imports` table at the V8 layer; this module stores the resulting entries. +void SetImportMapEntries(const std::vector>& entries); void SetVolatilePatterns(const std::vector& patterns); // Drop all per-process import-map / vendor / in-flight state. diff --git a/test-app/runtime/src/main/java/com/tns/Runtime.java b/test-app/runtime/src/main/java/com/tns/Runtime.java index 228cec272..64f0f6624 100644 --- a/test-app/runtime/src/main/java/com/tns/Runtime.java +++ b/test-app/runtime/src/main/java/com/tns/Runtime.java @@ -363,9 +363,9 @@ public static boolean isRemoteUrlAllowed(String url) { return true; } - // Check if URL matches any allowlist prefix + // Check if URL matches any allowlist entry on a component boundary for (String prefix : allowlist) { - if (url != null && prefix != null && url.startsWith(prefix)) { + if (url != null && prefix != null && remoteUrlMatchesAllowlistEntry(url, prefix)) { return true; } } @@ -373,6 +373,29 @@ public static boolean isRemoteUrlAllowed(String url) { return false; } + // Returns true when `url` is covered by allowlist `entry`, matching only on + // URL component boundaries (deny-by-default). Mirrors the native + // RemoteUrlMatchesAllowlistEntry in DevFlags.cpp. + private static boolean remoteUrlMatchesAllowlistEntry(String url, String entry) { + if (url == null || entry == null || entry.isEmpty()) { + return false; + } + if (url.length() < entry.length()) { + return false; + } + if (!url.startsWith(entry)) { + return false; + } + if (url.length() == entry.length()) { + return true; // exact match + } + if (entry.charAt(entry.length() - 1) == '/') { + return true; // entry ended at a boundary + } + char next = url.charAt(entry.length()); + return next == '/' || next == '?' || next == '#'; + } + /** * Returns the remote module allowlist as a String array for JNI. */ From 15db8553566b4a247638133b88bac12cb7a9a1af Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 7 Jun 2026 15:46:56 -0700 Subject: [PATCH 03/11] feat: reloadApplication for JS bundle restart without restarting app process Helpful for programmatic reset of JS isolate for clean restart of JS application as well as OTA (over-the-air) updates without restarting the entire app process. --- .../java/com/tns/NativeScriptRuntime.java | 14 +++++ .../src/main/java/com/tns/RuntimeHelper.java | 53 ++++++++++++++++++- .../runtime/src/main/cpp/com_tns_Runtime.cpp | 30 ++++++++++- .../src/main/java/com/tns/Runtime.java | 23 ++++++++ 4 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 test-app/app/src/main/java/com/tns/NativeScriptRuntime.java 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..e8eefefe2 --- /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(); + } +} 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..c6ead3f6b 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,40 @@ public static Runtime initRuntime(Context context) { } } + 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; @@ -295,7 +337,16 @@ public void onReceive(Context context, Intent intent) { } }; - context.registerReceiver(timezoneReceiver, timezoneFilter); + if (timezoneChangedReceiver != null) { + try { + context.unregisterReceiver(timezoneChangedReceiver); + } catch (IllegalArgumentException e) { + // Already unregistered. + } + } + + timezoneChangedReceiver = timezoneReceiver; + context.registerReceiver(timezoneChangedReceiver, timezoneFilter); } public static void initLiveSync(Application app) { diff --git a/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp b/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp index 5b6981156..3cb395cfb 100644 --- a/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp +++ b/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp @@ -2,6 +2,7 @@ #include "Runtime.h" #include "NativeScriptException.h" #include "CallbackHandlers.h" +#include "WorkerWrapper.h" #include #include @@ -352,6 +353,33 @@ extern "C" JNIEXPORT jint Java_com_tns_Runtime_getCurrentRuntimeIdLegacy(JNIEnv* return getCurrentRuntimeIdCritical_impl(); } +extern "C" JNIEXPORT void Java_com_tns_Runtime_TerminateRuntimeCallback(JNIEnv* env, jobject obj, jint runtimeId) { + auto runtime = TryGetRuntime(runtimeId); + if (runtime == nullptr) { + // TODO: Pete: Log message informing the developer of the failure + return; + } + + auto isolate = runtime->GetIsolate(); + + { + v8::Locker locker(isolate); + v8::Isolate::Scope isolate_scope(isolate); + v8::HandleScope handleScope(isolate); + + // Worker isolates are owned natively (WorkerWrapper registry), so tear + // down this runtime's child workers before destroying the isolate they + // are parented to. Each child cascades to its own nested workers. + WorkerWrapper::TerminateChildren(isolate); + + runtime->DestroyRuntime(); + } + + isolate->Dispose(); + + delete runtime; +} + extern "C" JNIEXPORT void Java_com_tns_Runtime_ResetDateTimeConfigurationCache(JNIEnv* _env, jobject obj, jint runtimeId) { auto runtime = TryGetRuntime(runtimeId); if (runtime == nullptr) { @@ -360,4 +388,4 @@ extern "C" JNIEXPORT void Java_com_tns_Runtime_ResetDateTimeConfigurationCache(J auto isolate = runtime->GetIsolate(); isolate->DateTimeConfigurationChangeNotification(Isolate::TimeZoneDetection::kRedetect); -} \ No newline at end of file +} diff --git a/test-app/runtime/src/main/java/com/tns/Runtime.java b/test-app/runtime/src/main/java/com/tns/Runtime.java index 64f0f6624..761be779b 100644 --- a/test-app/runtime/src/main/java/com/tns/Runtime.java +++ b/test-app/runtime/src/main/java/com/tns/Runtime.java @@ -105,6 +105,8 @@ public static void SetManualInstrumentationMode(String mode) { } } + private static native void TerminateRuntimeCallback(int runtimeId); + private static native void ResetDateTimeConfigurationCache(int runtimeId); void passUncaughtExceptionToJs(Throwable ex, String message, String fullStackTrace, String jsStackTrace) { @@ -504,6 +506,27 @@ public static boolean isInitialized() { return (runtime != null) ? runtime.isInitializedImpl() : false; } + static void destroyMainRuntime() { + Runtime runtime = Runtime.getCurrentRuntime(); + if (runtime == null) { + return; + } + + if (runtime.workerId != 0) { + throw new NativeScriptException("Only the main NativeScript runtime can be destroyed with destroyMainRuntime()."); + } + + GcListener.unsubscribe(runtime); + runtimeCache.remove(runtime.runtimeId); + currentRuntime.remove(); + + // Worker isolates are owned natively (WorkerWrapper registry), so the + // native TerminateRuntimeCallback terminates this runtime's child + // workers before destroying the main isolate. The Java side no longer + // tracks worker handlers/pending messages. + TerminateRuntimeCallback(runtime.runtimeId); + } + public int getWorkerId() { return workerId; } From e3cadab7b114274ff29cfe021816c6ee16bcd721 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Tue, 16 Jun 2026 11:44:02 -0700 Subject: [PATCH 04/11] chore: 9.1.0-alpha.6 --- package.json | 2 +- test-app/runtime/src/main/cpp/Version.h | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index e57f7a186..6f6c7dff2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@nativescript/android", "description": "NativeScript for Android using v8", - "version": "9.0.4", + "version": "9.1.0-alpha.6", "repository": { "type": "git", "url": "https://github.com/NativeScript/android.git" diff --git a/test-app/runtime/src/main/cpp/Version.h b/test-app/runtime/src/main/cpp/Version.h index 17348a68c..79a15b872 100644 --- a/test-app/runtime/src/main/cpp/Version.h +++ b/test-app/runtime/src/main/cpp/Version.h @@ -1,2 +1,2 @@ -#define NATIVE_SCRIPT_RUNTIME_VERSION "0.0.0.0" -#define NATIVE_SCRIPT_RUNTIME_COMMIT_SHA "RUNTIME_COMMIT_SHA_PLACEHOLDER" \ No newline at end of file +#define NATIVE_SCRIPT_RUNTIME_VERSION "9.1.0-alpha.5" +#define NATIVE_SCRIPT_RUNTIME_COMMIT_SHA "no commit sha was provided by build.gradle build" \ No newline at end of file From a0859d42254316d9d5731f493148c30d2ebf3857 Mon Sep 17 00:00:00 2001 From: Adrian Niculescu <15037449+adrian-niculescu@users.noreply.github.com> Date: Wed, 24 Jun 2026 05:23:18 +0300 Subject: [PATCH 05/11] fix: anchor relative dynamic imports at the file:// referrer's directory (#1976) A dynamic import() with a relative specifier from a module loaded over file:// resolved against the application root instead of the importing module's own directory, so a module outside the app root could not resolve its relative dependencies. ImportModuleDynamicallyCallback only used the referrer URL for http(s) referrers; for file:// it passed an empty referrer and the resolver fell back to the app root. Anchor the specifier at the referrer's directory from resource_name and pass the resolver an absolute file:// URL. Static imports are unaffected. Adds test-app specs for ./ and ../ dynamic imports from subdirectories plus an app-root control. --- .../assets/app/esm-subdir/nested/child.mjs | 5 ++ .../src/main/assets/app/esm-subdir/parent.mjs | 7 ++ .../main/assets/app/esm-subdir/sibling.mjs | 2 + .../assets/app/testRelativeDynamicImport.mjs | 6 ++ .../main/assets/app/tests/testESModules.mjs | 30 ++++++++ .../src/main/cpp/ModuleInternalCallbacks.cpp | 70 ++++++++++++++++++- 6 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 test-app/app/src/main/assets/app/esm-subdir/nested/child.mjs create mode 100644 test-app/app/src/main/assets/app/esm-subdir/parent.mjs create mode 100644 test-app/app/src/main/assets/app/esm-subdir/sibling.mjs create mode 100644 test-app/app/src/main/assets/app/testRelativeDynamicImport.mjs diff --git a/test-app/app/src/main/assets/app/esm-subdir/nested/child.mjs b/test-app/app/src/main/assets/app/esm-subdir/nested/child.mjs new file mode 100644 index 000000000..786d3b350 --- /dev/null +++ b/test-app/app/src/main/assets/app/esm-subdir/nested/child.mjs @@ -0,0 +1,5 @@ +// Performs a "../" relative dynamic import reaching up one directory. +export async function loadParentSibling() { + const sibling = await import("../sibling.mjs"); + return sibling.value; +} diff --git a/test-app/app/src/main/assets/app/esm-subdir/parent.mjs b/test-app/app/src/main/assets/app/esm-subdir/parent.mjs new file mode 100644 index 000000000..01f911a0b --- /dev/null +++ b/test-app/app/src/main/assets/app/esm-subdir/parent.mjs @@ -0,0 +1,7 @@ +// Performs a relative dynamic import of a sibling in the same subdirectory. +// The specifier "./sibling.mjs" must resolve against this module's directory, +// not the application root. +export async function loadSibling() { + const sibling = await import("./sibling.mjs"); + return sibling.value; +} diff --git a/test-app/app/src/main/assets/app/esm-subdir/sibling.mjs b/test-app/app/src/main/assets/app/esm-subdir/sibling.mjs new file mode 100644 index 000000000..4da8c2b58 --- /dev/null +++ b/test-app/app/src/main/assets/app/esm-subdir/sibling.mjs @@ -0,0 +1,2 @@ +// Sibling module reached via a relative dynamic import from the same directory. +export const value = "sibling-loaded"; diff --git a/test-app/app/src/main/assets/app/testRelativeDynamicImport.mjs b/test-app/app/src/main/assets/app/testRelativeDynamicImport.mjs new file mode 100644 index 000000000..50f7fba62 --- /dev/null +++ b/test-app/app/src/main/assets/app/testRelativeDynamicImport.mjs @@ -0,0 +1,6 @@ +// Control: a relative dynamic import from an app-root module, where the +// referrer's directory is the application root. Must keep resolving. +export async function loadRootSibling() { + const mod = await import("./testSimpleESModule.mjs"); + return mod.moduleType; +} diff --git a/test-app/app/src/main/assets/app/tests/testESModules.mjs b/test-app/app/src/main/assets/app/tests/testESModules.mjs index 02aaf7627..f9c40571b 100644 --- a/test-app/app/src/main/assets/app/tests/testESModules.mjs +++ b/test-app/app/src/main/assets/app/tests/testESModules.mjs @@ -31,4 +31,34 @@ describe("ES Modules", () => { expect(workerResults.urlObjectSupported).toBe(true); expect(workerResults.tildePathSupported).toBe(true); }); + + // These use the done-callback form: this Jasmine version only awaits a spec + // when its function declares an argument (an async function returning a + // promise is run synchronously and its result ignored). + it("resolves a relative dynamic import from a subdirectory module", (done) => { + import("~/esm-subdir/parent.mjs") + .then((parent) => parent.loadSibling()) + .then( + (value) => { expect(value).toBe("sibling-loaded"); done(); }, + (err) => { expect(err).toBeUndefined(); done(); } + ); + }); + + it("resolves a '../' relative dynamic import from a nested module", (done) => { + import("~/esm-subdir/nested/child.mjs") + .then((child) => child.loadParentSibling()) + .then( + (value) => { expect(value).toBe("sibling-loaded"); done(); }, + (err) => { expect(err).toBeUndefined(); done(); } + ); + }); + + it("still resolves a relative dynamic import from an app-root module", (done) => { + import("~/testRelativeDynamicImport.mjs") + .then((root) => root.loadRootSibling()) + .then( + (value) => { expect(value).toBe("ES Module"); done(); }, + (err) => { expect(err).toBeUndefined(); done(); } + ); + }); }); diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp index fd4da4dbf..c697fdd94 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp @@ -202,6 +202,54 @@ static void LogHttpCompileDiagnostics(v8::Isolate* isolate, snippet.c_str()); } +// Helper: collapse "." and ".." path segments, preserving a leading "/". +static std::string NormalizeDotSegments(const std::string& path) { + std::vector stack; + bool absolute = !path.empty() && path[0] == '/'; + size_t i = 0; + while (i <= path.size()) { + size_t j = path.find('/', i); + std::string seg = (j == std::string::npos) ? path.substr(i) : path.substr(i, j - i); + if (seg.empty() || seg == ".") { + // skip + } else if (seg == "..") { + if (!stack.empty()) stack.pop_back(); + } else { + stack.push_back(seg); + } + if (j == std::string::npos) break; + i = j + 1; + } + std::string norm = absolute ? "/" : std::string(); + for (size_t k = 0; k < stack.size(); k++) { + if (k > 0) norm += "/"; + norm += stack[k]; + } + return norm; +} + +// Helper: resolve a relative "./" or "../" specifier against a file:// referrer +// URL, returning an absolute file:// URL. Returns empty if not applicable. +static std::string ResolveFileRelative(const std::string& referrerUrl, const std::string& spec) { + const std::string filePrefix = "file://"; + if (referrerUrl.rfind(filePrefix, 0) != 0) { + return std::string(); + } + if (spec.empty() || spec[0] != '.') { + return std::string(); + } + // Referrer path: strip scheme, drop query and fragment + std::string refPath = referrerUrl.substr(filePrefix.size()); + size_t hashPos = refPath.find('#'); + if (hashPos != std::string::npos) refPath = refPath.substr(0, hashPos); + size_t qPos = refPath.find('?'); + if (qPos != std::string::npos) refPath = refPath.substr(0, qPos); + + size_t lastSlash = refPath.find_last_of('/'); + std::string baseDir = (lastSlash == std::string::npos) ? std::string("/") : refPath.substr(0, lastSlash + 1); + return filePrefix + NormalizeDotSegments(baseDir + spec); +} + // Resolution of relative / root-absolute import specifiers against an http(s) // referrer lives in `HMRSupport.cpp` (`ResolveImportSpecifierAgainstUrl`); call // sites below invoke `tns::ResolveImportSpecifierAgainstUrl(spec, referrer)`. @@ -1737,12 +1785,28 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( // Re-use the static resolver to locate / compile the module for non-HTTP cases. try { - // Pass empty referrer since this V8 version doesn't expose GetModule() on - // ScriptOrModule. The resolver will fall back to absolute-path heuristics. + // V8 exposes only the referrer's URL here (resource_name), not its Module, + // so anchor a relative specifier at the referrer's directory and hand the + // resolver an absolute file:// URL. Other specifiers pass through unchanged + // (the resolver applies its own ~/, bare and absolute heuristics). + v8::Local resolvedSpecifier = specifier; + if (specIsRelative) { + std::string fileResolved = ResolveFileRelative(referrerUrl, spec); + if (!fileResolved.empty()) { + resolvedSpecifier = ArgConverter::ConvertToV8String(isolate, fileResolved); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[esm][dyn][file-rel] base=%s spec=%s -> %s", + referrerUrl.c_str(), spec.c_str(), fileResolved.c_str()); + } + } + } + + // Pass empty referrer: this V8 version does not expose GetModule() on + // ScriptOrModule, and the specifier above is already absolute when needed. v8::Local refMod; v8::MaybeLocal maybeModule = - ResolveModuleCallback(context, specifier, import_assertions, refMod); + ResolveModuleCallback(context, resolvedSpecifier, import_assertions, refMod); v8::Local module; if (!maybeModule.ToLocal(&module)) { From 9504213dd14ebb8e10073eab976cb955fed38878 Mon Sep 17 00:00:00 2001 From: Adrian Niculescu <15037449+adrian-niculescu@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:45:00 +0300 Subject: [PATCH 06/11] fix: normalize "." and ".." in resolved module paths to dedupe modules (#1977) ResolveModuleCallback builds the on-disk path for a relative specifier without collapsing "." and ".." segments, so the same file imported as "./x" from /a/b and as "../x" from /a/b/c lands under two different registry keys. stat() resolves the segments so both locate the file, but the differing keys make V8 compile and evaluate the module twice: two instances with separate state, and import.meta.dirname becomes "/a/b/c/.." for the ".." spelling. Normalize the resolved path before it becomes the registry key, the same way the HTTP branch canonicalizes through CanonicalizeHttpUrlKey. The pass is lexical and runs for every on-disk candidate kind, and is a no-op for already-canonical paths. --- .../src/main/assets/app/esm-dedup/counter.mjs | 8 ++++++++ .../app/esm-dedup/nested/viaParentDir.mjs | 6 ++++++ .../main/assets/app/esm-dedup/viaSameDir.mjs | 6 ++++++ .../src/main/assets/app/tests/testESModules.mjs | 17 +++++++++++++++++ .../src/main/cpp/ModuleInternalCallbacks.cpp | 8 ++++++++ 5 files changed, 45 insertions(+) create mode 100644 test-app/app/src/main/assets/app/esm-dedup/counter.mjs create mode 100644 test-app/app/src/main/assets/app/esm-dedup/nested/viaParentDir.mjs create mode 100644 test-app/app/src/main/assets/app/esm-dedup/viaSameDir.mjs diff --git a/test-app/app/src/main/assets/app/esm-dedup/counter.mjs b/test-app/app/src/main/assets/app/esm-dedup/counter.mjs new file mode 100644 index 000000000..0d1750603 --- /dev/null +++ b/test-app/app/src/main/assets/app/esm-dedup/counter.mjs @@ -0,0 +1,8 @@ +// Singleton module: an ES module is evaluated once, so every importer must +// observe this same `state` object regardless of how the specifier spelled the +// path to this file. +export const state = { count: 0 }; + +export function increment() { + state.count++; +} diff --git a/test-app/app/src/main/assets/app/esm-dedup/nested/viaParentDir.mjs b/test-app/app/src/main/assets/app/esm-dedup/nested/viaParentDir.mjs new file mode 100644 index 000000000..22131116d --- /dev/null +++ b/test-app/app/src/main/assets/app/esm-dedup/nested/viaParentDir.mjs @@ -0,0 +1,6 @@ +// Reaches the same counter.mjs one directory up: "../counter.mjs". +import { state, increment } from "../counter.mjs"; + +increment(); + +export const seenState = state; diff --git a/test-app/app/src/main/assets/app/esm-dedup/viaSameDir.mjs b/test-app/app/src/main/assets/app/esm-dedup/viaSameDir.mjs new file mode 100644 index 000000000..a2de1ad06 --- /dev/null +++ b/test-app/app/src/main/assets/app/esm-dedup/viaSameDir.mjs @@ -0,0 +1,6 @@ +// Reaches counter.mjs as a same-directory sibling: "./counter.mjs". +import { state, increment } from "./counter.mjs"; + +increment(); + +export const seenState = state; diff --git a/test-app/app/src/main/assets/app/tests/testESModules.mjs b/test-app/app/src/main/assets/app/tests/testESModules.mjs index f9c40571b..23e932f26 100644 --- a/test-app/app/src/main/assets/app/tests/testESModules.mjs +++ b/test-app/app/src/main/assets/app/tests/testESModules.mjs @@ -61,4 +61,21 @@ describe("ES Modules", () => { (err) => { expect(err).toBeUndefined(); done(); } ); }); + + it("resolves './x' and '../x' to a single shared module instance", (done) => { + Promise.all([ + import("~/esm-dedup/viaSameDir.mjs"), + import("~/esm-dedup/nested/viaParentDir.mjs"), + ]).then( + ([sameDir, parentDir]) => { + // The same counter.mjs is reached as "./counter.mjs" and as + // "../counter.mjs"; it must be one module instance sharing one state + // object, incremented once per importer. + expect(sameDir.seenState).toBe(parentDir.seenState); + expect(sameDir.seenState.count).toBe(2); + done(); + }, + (err) => { expect(err).toBeUndefined(); done(); } + ); + }); }); diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp index c697fdd94..9149f2e3d 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp @@ -1380,6 +1380,14 @@ v8::MaybeLocal ResolveModuleCallback(v8::Local context, if (found) break; } + // Canonicalize "." / ".." segments so a file reached through different + // spellings (e.g. "./x" from /a/b and "../x" from /a/b/c both name /a/b/x) + // maps to one registry key and is compiled once. The HTTP branch + // canonicalizes via CanonicalizeHttpUrlKey. + if (found) { + absPath = NormalizeDotSegments(absPath); + } + // 6) Handle special cases if file not found if (!found) { // Check for Node.js built-in modules From 3c95a3ef3408939f01b8d3be49573ab37a3b37fe Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Fri, 3 Jul 2026 16:33:07 -0700 Subject: [PATCH 07/11] refactor(runtime): reduce surface to a mechanism-only dev-loader contract The runtime explicitly does not implement HMR policy. import.meta.hot, the hot-data/accept/dispose/prune registries, and dev-session state move to the JS HMR clients (eg, @nativescript/vite); native keeps only the sync HTTP module fetch, prewarm cache + list-mode kickstart, eviction plumbing, and the dev-boot-complete signal. Dev helpers are consolidated under __NS_DEV__. Also fixes dynamic import() error propagation: with top-level-await enabled, Module::Evaluate() returns a promise instead of an empty MaybeLocal on throw, so the previous empty-check never fired and import() resolved a half-evaluated namespace, silently swallowing module evaluation errors. The callback now checks Module::GetStatus() for kErrored (HTTP, blob, and generic paths) and rejects with the module's real exception, matching iOS. Removes the httpModulePrefetch app-config flag and speculative prefetching; list-mode kickstart remains. --- package.json | 2 +- test-app/app/src/main/assets/app/mainpage.js | 2 +- .../assets/app/tests/esm/hmr/hot-data-ext.js | 79 - .../assets/app/tests/esm/hmr/hot-data-ext.mjs | 79 - .../main/assets/app/tests/esm/meta-no-hot.mjs | 5 + .../assets/app/tests/testHmrHotDataExt.mjs | 65 - .../assets/app/tests/testHttpCanonicalKey.mjs | 69 +- .../testNodeBuiltinsAndOptionalModules.mjs | 7 +- .../assets/app/tests/testNsDevBoundary.mjs | 62 + .../runtime/src/main/cpp/CallbackHandlers.cpp | 2 +- .../runtime/src/main/cpp/CallbackHandlers.h | 12 +- test-app/runtime/src/main/cpp/DevFlags.cpp | 34 - test-app/runtime/src/main/cpp/DevFlags.h | 6 - test-app/runtime/src/main/cpp/HMRSupport.cpp | 2546 ++++------------- test-app/runtime/src/main/cpp/HMRSupport.h | 396 +-- .../runtime/src/main/cpp/MetadataNode.cpp | 17 +- .../src/main/cpp/ModuleInternalCallbacks.cpp | 63 +- .../src/main/cpp/ModuleInternalCallbacks.h | 17 +- test-app/runtime/src/main/cpp/Runtime.cpp | 55 +- test-app/runtime/src/main/cpp/URLImpl.h | 4 +- test-app/runtime/src/main/cpp/Version.h | 2 +- .../runtime/src/main/cpp/WorkerWrapper.cpp | 7 +- test-app/runtime/src/main/cpp/WorkerWrapper.h | 5 +- .../src/main/java/com/tns/AppConfig.java | 9 - .../src/main/java/com/tns/Runtime.java | 13 - 25 files changed, 840 insertions(+), 2718 deletions(-) delete mode 100644 test-app/app/src/main/assets/app/tests/esm/hmr/hot-data-ext.js delete mode 100644 test-app/app/src/main/assets/app/tests/esm/hmr/hot-data-ext.mjs create mode 100644 test-app/app/src/main/assets/app/tests/esm/meta-no-hot.mjs delete mode 100644 test-app/app/src/main/assets/app/tests/testHmrHotDataExt.mjs create mode 100644 test-app/app/src/main/assets/app/tests/testNsDevBoundary.mjs diff --git a/package.json b/package.json index 6f6c7dff2..1c1d5069c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@nativescript/android", "description": "NativeScript for Android using v8", - "version": "9.1.0-alpha.6", + "version": "9.1.0-alpha.7", "repository": { "type": "git", "url": "https://github.com/NativeScript/android.git" diff --git a/test-app/app/src/main/assets/app/mainpage.js b/test-app/app/src/main/assets/app/mainpage.js index 2142b3173..fb03e6ba8 100644 --- a/test-app/app/src/main/assets/app/mainpage.js +++ b/test-app/app/src/main/assets/app/mainpage.js @@ -77,6 +77,6 @@ require('./tests/testQueueMicrotask'); require("./tests/testConcurrentAccess"); require("./tests/testESModules.mjs"); -require("./tests/testHmrHotDataExt.mjs"); +require("./tests/testNsDevBoundary.mjs"); require("./tests/testHttpCanonicalKey.mjs"); require("./tests/testNodeBuiltinsAndOptionalModules.mjs"); diff --git a/test-app/app/src/main/assets/app/tests/esm/hmr/hot-data-ext.js b/test-app/app/src/main/assets/app/tests/esm/hmr/hot-data-ext.js deleted file mode 100644 index 64e1d4816..000000000 --- a/test-app/app/src/main/assets/app/tests/esm/hmr/hot-data-ext.js +++ /dev/null @@ -1,79 +0,0 @@ -// HMR hot.data test module (.js). -// -// INTENTIONAL twin of hot-data-ext.mjs. Two physical files with -// different extensions are required so the HMR canonical-key -// extension-collapse path is actually exercised by tests that import -// BOTH variants (see testHmrHotDataExt -// "should share hot.data across .mjs and .js variants"). Each file -// MUST own its own `import.meta.hot` reference — re-exporting from the -// sibling would defeat the test, because `dataMjs === dataJs` would -// then hold trivially via function identity instead of validating the -// runtime's canonical-key normalization. -// -// Keep the body in lock-step with `hot-data-ext.mjs`. - -export function getHot() { - return (typeof import.meta !== "undefined" && import.meta) ? import.meta.hot : undefined; -} - -export function getHotData() { - const hot = getHot(); - return hot ? hot.data : undefined; -} - -export function setHotValue(value) { - const hot = getHot(); - if (!hot || !hot.data) { - throw new Error("import.meta.hot.data is not available"); - } - hot.data.value = value; - return hot.data.value; -} - -export function getHotValue() { - const hot = getHot(); - return hot && hot.data ? hot.data.value : undefined; -} - -export function testHotApi() { - const hot = getHot(); - const result = { - ok: false, - hasHot: !!hot, - hasData: !!(hot && hot.data), - hasAccept: !!(hot && typeof hot.accept === "function"), - hasDispose: !!(hot && typeof hot.dispose === "function"), - hasDecline: !!(hot && typeof hot.decline === "function"), - hasInvalidate: !!(hot && typeof hot.invalidate === "function"), - hasPrune: !!(hot && typeof hot.prune === "function"), - }; - - try { - if (hot && typeof hot.accept === "function") { - hot.accept(function () {}); - } - if (hot && typeof hot.dispose === "function") { - hot.dispose(function () {}); - } - if (hot && typeof hot.decline === "function") { - hot.decline(); - } - if (hot && typeof hot.invalidate === "function") { - hot.invalidate(); - } - result.ok = - result.hasHot && - result.hasData && - result.hasAccept && - result.hasDispose && - result.hasDecline && - result.hasInvalidate && - result.hasPrune; - } catch (e) { - result.error = (e && e.message) ? e.message : String(e); - } - - return result; -} - -console.log("HMR hot.data ext module loaded (.js)"); diff --git a/test-app/app/src/main/assets/app/tests/esm/hmr/hot-data-ext.mjs b/test-app/app/src/main/assets/app/tests/esm/hmr/hot-data-ext.mjs deleted file mode 100644 index 7ff66c3b9..000000000 --- a/test-app/app/src/main/assets/app/tests/esm/hmr/hot-data-ext.mjs +++ /dev/null @@ -1,79 +0,0 @@ -// HMR hot.data test module (.mjs). -// -// INTENTIONAL twin of hot-data-ext.js. Two physical files with -// different extensions are required so the HMR canonical-key -// extension-collapse path is actually exercised by tests that import -// BOTH variants (see testHmrHotDataExt -// "should share hot.data across .mjs and .js variants"). Each file -// MUST own its own `import.meta.hot` reference — re-exporting from the -// sibling would defeat the test, because `dataMjs === dataJs` would -// then hold trivially via function identity instead of validating the -// runtime's canonical-key normalization. -// -// Keep the body in lock-step with `hot-data-ext.js`. - -export function getHot() { - return (typeof import.meta !== "undefined" && import.meta) ? import.meta.hot : undefined; -} - -export function getHotData() { - const hot = getHot(); - return hot ? hot.data : undefined; -} - -export function setHotValue(value) { - const hot = getHot(); - if (!hot || !hot.data) { - throw new Error("import.meta.hot.data is not available"); - } - hot.data.value = value; - return hot.data.value; -} - -export function getHotValue() { - const hot = getHot(); - return hot && hot.data ? hot.data.value : undefined; -} - -export function testHotApi() { - const hot = getHot(); - const result = { - ok: false, - hasHot: !!hot, - hasData: !!(hot && hot.data), - hasAccept: !!(hot && typeof hot.accept === "function"), - hasDispose: !!(hot && typeof hot.dispose === "function"), - hasDecline: !!(hot && typeof hot.decline === "function"), - hasInvalidate: !!(hot && typeof hot.invalidate === "function"), - hasPrune: !!(hot && typeof hot.prune === "function"), - }; - - try { - if (hot && typeof hot.accept === "function") { - hot.accept(function () {}); - } - if (hot && typeof hot.dispose === "function") { - hot.dispose(function () {}); - } - if (hot && typeof hot.decline === "function") { - hot.decline(); - } - if (hot && typeof hot.invalidate === "function") { - hot.invalidate(); - } - result.ok = - result.hasHot && - result.hasData && - result.hasAccept && - result.hasDispose && - result.hasDecline && - result.hasInvalidate && - result.hasPrune; - } catch (e) { - result.error = (e && e.message) ? e.message : String(e); - } - - return result; -} - -console.log("HMR hot.data ext module loaded (.mjs)"); diff --git a/test-app/app/src/main/assets/app/tests/esm/meta-no-hot.mjs b/test-app/app/src/main/assets/app/tests/esm/meta-no-hot.mjs new file mode 100644 index 000000000..2f7a26b88 --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/esm/meta-no-hot.mjs @@ -0,0 +1,5 @@ +// Fixture for testNsDevBoundary: reports whether the runtime attached a +// native `import.meta.hot` (it must NOT — hot contexts are injected by the +// JS dev client via source rewrite, never by the runtime). +export const hasHot = typeof import.meta.hot !== "undefined"; +export const hotValue = import.meta.hot; diff --git a/test-app/app/src/main/assets/app/tests/testHmrHotDataExt.mjs b/test-app/app/src/main/assets/app/tests/testHmrHotDataExt.mjs deleted file mode 100644 index d9c8e7a15..000000000 --- a/test-app/app/src/main/assets/app/tests/testHmrHotDataExt.mjs +++ /dev/null @@ -1,65 +0,0 @@ -// HMR import.meta.hot.data sharing tests. -// -// These tests exercise the canonical-key extension-collapse path: -// importing the *same* logical module under `.mjs` and `.js` extensions -// MUST yield the same `import.meta.hot.data` object identity, so that -// state written from one variant is observable in the other. -// -// The two fixture files under `tests/esm/hmr/` MUST remain independent -// (no re-export of one from the other) — see the comment header in -// each fixture for why. -// -// HTTP-loader variants of these tests (live-tagged, boot-tagged, and -// /ns/core bridge URLs) live in HttpEsmLoaderTests on iOS. They depend -// on a dev-server harness that Android does not currently stand up, -// and are intentionally not ported here. The local twin-file path -// below still exercises the core canonical-key normalization. - -describe("HMR hot.data", function () { - it("exposes the import.meta.hot API surface", async function () { - const mod = await import("~/tests/esm/hmr/hot-data-ext.mjs"); - expect(mod).toBeTruthy(); - expect(typeof mod.testHotApi).toBe("function"); - - const result = mod.testHotApi(); - expect(result).toBeTruthy(); - if (!result.hasHot) { - pending("import.meta.hot not available (release build?)"); - return; - } - - expect(result.hasData).toBe(true); - expect(result.hasAccept).toBe(true); - expect(result.hasDispose).toBe(true); - expect(result.hasDecline).toBe(true); - expect(result.hasInvalidate).toBe(true); - expect(result.hasPrune).toBe(true); - expect(result.ok).toBe(true); - }); - - it("should share hot.data across .mjs and .js variants", async function () { - const [mjs, js] = await Promise.all([ - import("~/tests/esm/hmr/hot-data-ext.mjs"), - import("~/tests/esm/hmr/hot-data-ext.js"), - ]); - - const hotMjs = mjs && typeof mjs.getHot === "function" ? mjs.getHot() : null; - const hotJs = js && typeof js.getHot === "function" ? js.getHot() : null; - if (!hotMjs || !hotJs) { - pending("import.meta.hot not available (release build?)"); - return; - } - - const dataMjs = mjs.getHotData(); - const dataJs = js.getHotData(); - expect(dataMjs).toBeDefined(); - expect(dataJs).toBeDefined(); - - const token = "tok_" + Date.now() + "_" + Math.random(); - mjs.setHotValue(token); - expect(js.getHotValue()).toBe(token); - - // Canonical hot key strips common script extensions, so these must share identity. - expect(dataMjs).toBe(dataJs); - }); -}); diff --git a/test-app/app/src/main/assets/app/tests/testHttpCanonicalKey.mjs b/test-app/app/src/main/assets/app/tests/testHttpCanonicalKey.mjs index c4b092cc7..33ee93e90 100644 --- a/test-app/app/src/main/assets/app/tests/testHttpCanonicalKey.mjs +++ b/test-app/app/src/main/assets/app/tests/testHttpCanonicalKey.mjs @@ -1,52 +1,77 @@ // HTTP canonical-key identity tests. // // Pins the behavior of the native CanonicalizeHttpUrlKey (the loader/registry -// key) via the debug-only __nsCanonicalizeHttpUrlKey diagnostic global. Pure -// string logic — no dev server required. Android does NOT collapse the -// __ns_boot__/__ns_hmr__ virtual prefixes here (that is canonicalHotKey's job), -// which these specs assert explicitly. +// key) via the debug-only __NS_DEV__.canonicalizeHttpUrlKey diagnostic. Pure +// string logic — no dev server required. +// +// Module identity IS the canonical URL: the dev server serves every module +// under one URL and freshness is handled by __NS_DEV__.invalidateModules +// (registry + prewarm-cache evict + fetch nonce), never by URL variation. +// There is deliberately no path-tag vocabulary (__ns_boot__/__ns_hmr__) +// to collapse, and no versioned-bridge-endpoint normalization. describe("HTTP canonical key", function () { + function canonFn() { + const dev = globalThis.__NS_DEV__; + return dev && typeof dev.canonicalizeHttpUrlKey === "function" + ? dev.canonicalizeHttpUrlKey + : null; + } function canon(url) { - return globalThis.__nsCanonicalizeHttpUrlKey(url); + return canonFn()(url); } it("is available in dev builds", function () { - if (typeof globalThis.__nsCanonicalizeHttpUrlKey !== "function") { - pending("__nsCanonicalizeHttpUrlKey not available (release build?)"); + if (!canonFn()) { + pending("__NS_DEV__.canonicalizeHttpUrlKey not available (release build?)"); return; } - expect(typeof globalThis.__nsCanonicalizeHttpUrlKey).toBe("function"); + expect(typeof canonFn()).toBe("function"); }); it("drops the fragment and unwraps file://http wrappers", function () { - if (typeof globalThis.__nsCanonicalizeHttpUrlKey !== "function") { pending("release"); return; } + if (!canonFn()) { pending("release"); return; } expect(canon("http://h/ns/m/foo.js#frag")).toBe("http://h/ns/m/foo.js"); expect(canon("file://http://h/x.js")).toBe("http://h/x.js"); }); - it("normalizes versioned bridge endpoints but not deeper paths", function () { - if (typeof globalThis.__nsCanonicalizeHttpUrlKey !== "function") { pending("release"); return; } - expect(canon("http://h/ns/rt/42")).toBe("http://h/ns/rt"); - expect(canon("http://h/ns/core/13")).toBe("http://h/ns/core"); + it("does NOT collapse versioned endpoint paths or path tags", function () { + if (!canonFn()) { pending("release"); return; } + // Path is identity — no /ns/rt/ → /ns/rt collapse, no + // __ns_hmr__/__ns_boot__ tag folding. + expect(canon("http://h/ns/rt/42")).toBe("http://h/ns/rt/42"); expect(canon("http://h/ns/rt/42/x.js")).toBe("http://h/ns/rt/42/x.js"); - }); - - it("does NOT collapse __ns_hmr__/__ns_boot__ prefixes (Android loader key)", function () { - if (typeof globalThis.__nsCanonicalizeHttpUrlKey !== "function") { pending("release"); return; } expect(canon("http://h/ns/m/__ns_hmr__/v7/foo.js")) .toBe("http://h/ns/m/__ns_hmr__/v7/foo.js"); }); - it("strips ?import and sorts remaining query params", function () { - if (typeof globalThis.__nsCanonicalizeHttpUrlKey !== "function") { pending("release"); return; } - expect(canon("http://h/a?import=1&b=2&a=3")).toBe("http://h/a?a=3&b=2"); - expect(canon("http://h/a?b=2&a=1")).toBe("http://h/a?a=1&b=2"); + it("strips import/t/v markers and sorts remaining params on dev endpoints", function () { + if (!canonFn()) { pending("release"); return; } + expect(canon("http://h/ns/m/a?import=1&b=2&a=3")).toBe("http://h/ns/m/a?a=3&b=2"); + expect(canon("http://h/ns/m/a?b=2&a=1")).toBe("http://h/ns/m/a?a=1&b=2"); expect(canon("http://h/ns/core?import=1")).toBe("http://h/ns/core"); + expect(canon("http://h/ns/m/a?t=123&v=abc&x=1")).toBe("http://h/ns/m/a?x=1"); + }); + + it("preserves the query verbatim on non-dev endpoints", function () { + if (!canonFn()) { pending("release"); return; } + // Public-internet module URLs: the query can be part of identity + // (auth, content versioning, routing) — only the fragment is dropped. + expect(canon("http://h/a?import=1&b=2&a=3")).toBe("http://h/a?import=1&b=2&a=3"); + expect(canon("https://cdn.example.com/pkg.js?token=x#frag")) + .toBe("https://cdn.example.com/pkg.js?token=x"); + }); + + it("preserves the t param on @ng/component endpoints", function () { + if (!canonFn()) { pending("release"); return; } + // Angular HMR component-update endpoint: `t` identifies a specific + // recompile and must remain a distinct registry entry. + expect(canon("http://h/ns/m/app/@ng/component?c=x&t=111")) + .toBe("http://h/ns/m/app/@ng/component?c=x&t=111"); }); it("leaves a non-http(s) specifier unchanged", function () { - if (typeof globalThis.__nsCanonicalizeHttpUrlKey !== "function") { pending("release"); return; } + if (!canonFn()) { pending("release"); return; } expect(canon("~/local/foo.js")).toBe("~/local/foo.js"); }); }); diff --git a/test-app/app/src/main/assets/app/tests/testNodeBuiltinsAndOptionalModules.mjs b/test-app/app/src/main/assets/app/tests/testNodeBuiltinsAndOptionalModules.mjs index c944fce6b..2af282e34 100644 --- a/test-app/app/src/main/assets/app/tests/testNodeBuiltinsAndOptionalModules.mjs +++ b/test-app/app/src/main/assets/app/tests/testNodeBuiltinsAndOptionalModules.mjs @@ -1,4 +1,4 @@ -// Tests the resolver paths added in the HMR/ESM hardening port: +// Tests the ESM resolver's synthetic-module paths: // - node: built-in polyfills (in-memory ES modules) // - bare-specifier optional-module placeholders // - ns-vendor:// vendor-registry resolution via configureRuntime importMap @@ -46,9 +46,10 @@ describe("Node built-in and optional module resolution", function () { }); it("resolves import-map vendor modules through the explicit vendor registry", async function () { - const configureRuntime = globalThis.__nsConfigureDevRuntime || globalThis.__nsConfigureRuntime; + const dev = globalThis.__NS_DEV__; + const configureRuntime = dev && dev.configureRuntime; if (typeof configureRuntime !== "function") { - pending("__nsConfigureDevRuntime not available (release build?)"); + pending("__NS_DEV__.configureRuntime not available"); return; } diff --git a/test-app/app/src/main/assets/app/tests/testNsDevBoundary.mjs b/test-app/app/src/main/assets/app/tests/testNsDevBoundary.mjs new file mode 100644 index 000000000..6850bc744 --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testNsDevBoundary.mjs @@ -0,0 +1,62 @@ +// Dev-loader boundary tests. +// +// Pins the mechanism-only native contract: the runtime exposes exactly the +// `__NS_DEV__` namespace (configureRuntime, invalidateModules, +// kickstartPrefetch, getLoadedModuleUrls, setDevBootComplete, +// terminateAllWorkers, and the debug-only canonicalizeHttpUrlKey), and +// nothing else. HMR *policy* — `import.meta.hot`, hot-data/accept/dispose +// registries, dev-session state, boot orchestration — lives in the JS dev +// client (@nativescript/vite), not in the runtime. + +describe("__NS_DEV__ dev-loader boundary", function () { + it("exposes the __NS_DEV__ namespace with the core primitives", function () { + const dev = globalThis.__NS_DEV__; + expect(dev).toBeDefined(); + expect(typeof dev.configureRuntime).toBe("function"); + expect(typeof dev.invalidateModules).toBe("function"); + expect(typeof dev.kickstartPrefetch).toBe("function"); + expect(typeof dev.getLoadedModuleUrls).toBe("function"); + expect(typeof dev.setDevBootComplete).toBe("function"); + // Main isolate: worker termination is installed here (and ONLY here — + // worker isolates must not receive it). + expect(typeof dev.terminateAllWorkers).toBe("function"); + }); + + it("keeps the dev surface confined to __NS_DEV__ (no flat __ns* globals)", function () { + // The contract is the single namespace object: no dev primitive is + // reachable as a flat global, so tooling can feature-detect exactly + // one thing and the global namespace stays unpolluted. + [ + "__nsConfigureRuntime", + "__nsConfigureDevRuntime", + "__nsInvalidateModules", + "__nsKickstartHmrPrefetch", + "__nsGetLoadedModuleUrls", + "__nsSetDevBootComplete", + "__nsTerminateAllWorkers", + "__nsCanonicalizeHttpUrlKey", + "__nsStartDevSession", + "__nsGetHotData", + "__nsRegisterHotAccept", + "__nsRegisterHotDispose", + ].forEach(function (name) { + expect(typeof globalThis[name]).toBe("undefined"); + }); + }); + + it("does not attach import.meta.hot natively", async function () { + const mod = await import("~/tests/esm/meta-no-hot.mjs"); + // Hot contexts are injected by the JS dev client via source rewrite; + // a module loaded outside a dev session must see no hot object. + expect(mod.hasHot).toBe(false); + expect(mod.hotValue).toBeUndefined(); + }); + + it("getLoadedModuleUrls returns an array of registry keys", function () { + const urls = globalThis.__NS_DEV__.getLoadedModuleUrls(); + expect(Array.isArray(urls)).toBe(true); + urls.forEach(function (u) { + expect(typeof u).toBe("string"); + }); + }); +}); diff --git a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp index 09d04865c..54506911a 100644 --- a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp +++ b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp @@ -1347,7 +1347,7 @@ CallbackHandlers::WorkerObjectTerminateCallback(const v8::FunctionCallbackInfo &args) { - // `globalThis.__nsTerminateAllWorkers()` — main-isolate-only HMR helper. + // `__NS_DEV__.terminateAllWorkers()` — main-isolate-only dev helper. // Tears down every worker parented by this isolate through the WorkerWrapper // registry. TerminateChildren snapshots the registry under its lock, // terminates and clears each worker, and lets each one cascade into its own diff --git a/test-app/runtime/src/main/cpp/CallbackHandlers.h b/test-app/runtime/src/main/cpp/CallbackHandlers.h index 37f17c5ba..ef5550adf 100644 --- a/test-app/runtime/src/main/cpp/CallbackHandlers.h +++ b/test-app/runtime/src/main/cpp/CallbackHandlers.h @@ -149,11 +149,13 @@ namespace tns { static void WorkerGlobalCloseCallback(const v8::FunctionCallbackInfo &args); /* - * `globalThis.__nsTerminateAllWorkers()`, installed on the main-thread - * isolate only (debug builds). Terminates every worker parented by the - * main isolate via WorkerWrapper::TerminateChildren, which snapshots the - * registry, terminates and clears each worker, and lets each one cascade - * into its own nested workers. Returns the number of direct (top-level) + * `__NS_DEV__.terminateAllWorkers()`, installed on the main-thread + * isolate only (see HMRSupport's InitializeHmrDevGlobals — worker + * threads do NOT receive it, so a stuck worker can't take down its + * peers). Terminates every worker parented by the main isolate via + * WorkerWrapper::TerminateChildren, which snapshots the registry, + * terminates and clears each worker, and lets each one cascade into + * its own nested workers. Returns the number of direct (top-level) * workers torn down so the HMR client can log it. */ static void TerminateAllWorkersCallback(const v8::FunctionCallbackInfo &args); diff --git a/test-app/runtime/src/main/cpp/DevFlags.cpp b/test-app/runtime/src/main/cpp/DevFlags.cpp index eefa68f82..c4cec187b 100644 --- a/test-app/runtime/src/main/cpp/DevFlags.cpp +++ b/test-app/runtime/src/main/cpp/DevFlags.cpp @@ -47,40 +47,6 @@ bool IsScriptLoadingLogEnabled() { return CachedBoolFlagFromJava(cached, initFlag, "getLogScriptLoadingEnabled"); } -// HTTP module loader flags -// -// Reads `httpModulePrefetch` from app config (default: DISABLED). -// -// Apps that want to opt in for testing can set in package.json: -// -// { -// "httpModulePrefetch": true -// } -// -// Returning false here short-circuits both the speculative-prefetch cache -// lookup (in HttpFetchText) and the prefetch wave (in KickstartHmrPrefetchSync / -// KickstartHmrPrefetchUrlsSync), restoring the pre-prefetcher behavior -// bit-for-bit. This is layered on top of the IsRemoteUrlAllowed network gate. -bool IsHttpModulePrefetchEnabled() { - static std::atomic cached{-1}; - static std::once_flag initFlag; - bool enabled = CachedBoolFlagFromJava(cached, initFlag, "getHttpModulePrefetchEnabled"); - - // Startup banner. Gated on the logScriptLoading flag so it stays silent - // by default — flip the flag in package.json when diagnosing why - // prefetch is or isn't engaging. - // [http-loader] prefetch=disabled ← expected default - // [http-loader] prefetch=enabled ← only if config opt-in - static std::once_flag bannerFlag; - std::call_once(bannerFlag, [enabled]() { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-loader] prefetch=%s shared-session=on hmr-kickstart=on", - enabled ? "enabled" : "disabled"); - } - }); - return enabled; -} - // Default OFF because the volume is high (one line per fetch, hundreds per // cold boot, hundreds per HMR refresh). Opt in via package.json: // { "httpFetchUrlLog": true } diff --git a/test-app/runtime/src/main/cpp/DevFlags.h b/test-app/runtime/src/main/cpp/DevFlags.h index ec6bea410..f3f6dca14 100644 --- a/test-app/runtime/src/main/cpp/DevFlags.h +++ b/test-app/runtime/src/main/cpp/DevFlags.h @@ -11,12 +11,6 @@ bool IsScriptLoadingLogEnabled(); // HTTP module loader flags // -// Returns true when speculative HTTP module prefetching (the dep-graph BFS -// kicked off after each successful HttpFetchText) should be enabled. Default -// OFF so cold-boot behaviour is unchanged for users who have not opted in. -// Controlled by package.json: "httpModulePrefetch": true|false -bool IsHttpModulePrefetchEnabled(); - // Returns true when one log line should be emitted per HTTP fetch URL. // Default OFF because the volume is high (one line per fetch, hundreds per // cold boot, hundreds per HMR refresh). Opt in via package.json: diff --git a/test-app/runtime/src/main/cpp/HMRSupport.cpp b/test-app/runtime/src/main/cpp/HMRSupport.cpp index 8e37e2096..ce91f9927 100644 --- a/test-app/runtime/src/main/cpp/HMRSupport.cpp +++ b/test-app/runtime/src/main/cpp/HMRSupport.cpp @@ -1,13 +1,21 @@ // HMRSupport.cpp +// +// The native half of the NativeScript dev-loader contract. The runtime +// exposes *mechanism* only (sync HTTP module fetch, prewarm cache + +// list-mode kickstart, eviction plumbing, dev-boot-complete signal), +// consolidated under the single `__NS_DEV__` namespace object. All HMR +// *policy* — boot orchestration, `import.meta.hot`, hot-callback +// registries, full reload, CSS apply, WebSocket protocol — lives in the +// JS dev client (`@nativescript/vite`). #include "HMRSupport.h" #include "ArgConverter.h" +#include "CallbackHandlers.h" #include "DevFlags.h" #include "JEnv.h" #include "ModuleInternalCallbacks.h" #include "NativeScriptAssert.h" #include "NativeScriptException.h" -#include "Runtime.h" #include #include @@ -26,18 +34,8 @@ namespace tns { // ────────────────────────────────────────────────────────────────────────── -// Resolver-side helpers used by the dev-session machinery below -// (`ApplyDevRuntimeConfigObject`, `CollectSessionModuleUrls`). The actual -// definitions live in ModuleInternalCallbacks.cpp; this header-style -// forward block lets HMRSupport.cpp call them without pulling the -// resolver header in (avoids a circular include). -void SetImportMapEntries(const std::vector>& entries); -void SetVolatilePatterns(const std::vector& patterns); -std::vector GetLoadedModuleUrls(); - -// ────────────────────────────────────────────────────────────────────────── -// Local v8 string helper: thin convenience wrapper around the existing -// `ArgConverter::ConvertToV8String` so call sites can read more compactly. +// Local v8 string helper: thin convenience wrapper so call sites can read +// more compactly. static inline v8::Local ToV8String(v8::Isolate* isolate, const char* str) { if (str == nullptr) { return v8::String::Empty(isolate); @@ -55,82 +53,12 @@ static inline bool EndsWith(const std::string& s, const char* suffix) { return s.size() >= n && s.compare(s.size() - n, n, suffix) == 0; } -// Per-module hot data and callbacks. Keyed by canonical module path. -// Heap-allocated (leaky singleton) to prevent V8 crash during __cxa_finalize_ranges. -// See g_moduleRegistry comment in ModuleInternalCallbacks.cpp for full rationale. -static auto* _g_hotData = new std::unordered_map>(); -static auto& g_hotData = *_g_hotData; -static auto* _g_hotAccept = new std::unordered_map>>(); -static auto& g_hotAccept = *_g_hotAccept; -static auto* _g_hotDispose = new std::unordered_map>>(); -static auto& g_hotDispose = *_g_hotDispose; -// Per-module prune callbacks (`import.meta.hot.prune(cb)`). Symmetric with -// `g_hotDispose` — separate registry because Vite spec semantics differ: -// `dispose` fires on every replacement (every HMR cycle), `prune` fires -// only when the module is removed from the dependency graph entirely. -static auto* _g_hotPrune = new std::unordered_map>>(); -static auto& g_hotPrune = *_g_hotPrune; - -// Custom event listeners -// Keyed by event name (global, not per-module) -static auto* _g_hotEventListeners = new std::unordered_map>>(); -static auto& g_hotEventListeners = *_g_hotEventListeners; - -// Set of canonical module keys that called `import.meta.hot.decline()`. -// The HMR client checks this set before applying an update — if any update -// touches a declined key, the update converts to a full reload. No V8 -// handles to clean up (just strings), so this lives in a plain set with -// its own mutex for thread safety. -static std::unordered_set g_hotDeclined; -static std::mutex g_hotDeclinedMutex; - -// Active deterministic dev-session state. -static DevSessionState g_activeDevSession; -static std::mutex g_activeDevSessionMutex; - -bool GetOptionalStringProperty(v8::Isolate* isolate, v8::Local context, - v8::Local object, const char* key, - std::string* out) { - if (out == nullptr) return false; - - v8::Local value; - if (!object->Get(context, ToV8String(isolate, key)).ToLocal(&value) || - value->IsUndefined() || value->IsNull()) { - return false; - } - - v8::Local stringValue; - if (!value->ToString(context).ToLocal(&stringValue)) { - return false; - } - - v8::String::Utf8Value utf8(isolate, stringValue); - *out = *utf8 ? *utf8 : ""; - return true; -} - -v8::Local CreateResolvedPromise(v8::Isolate* isolate, - v8::Local context) { - v8::Local resolver = - v8::Promise::Resolver::New(context).ToLocalChecked(); - resolver->Resolve(context, v8::Undefined(isolate)).FromMaybe(false); - return resolver->GetPromise(); -} - -v8::Local CreateRejectedPromise(v8::Local context, - v8::Local reason) { - v8::Local resolver = - v8::Promise::Resolver::New(context).ToLocalChecked(); - resolver->Reject(context, reason).FromMaybe(false); - return resolver->GetPromise(); -} - -void MirrorFunctionOnGlobalThis(v8::Isolate* isolate, v8::Local context, - const char* name) { +void MirrorGlobalOnGlobalThis(v8::Isolate* isolate, v8::Local context, + const char* name) { std::string src = "if (typeof globalThis !== 'undefined' && typeof globalThis." + std::string(name) + - " !== 'function') {" + " === 'undefined') {" " Object.defineProperty(globalThis, '" + std::string(name) + "', { value: this." + std::string(name) + ", writable: true, configurable: true, enumerable: false });" @@ -143,21 +71,6 @@ void MirrorFunctionOnGlobalThis(v8::Isolate* isolate, v8::Local con } } -static bool GetOptionalBooleanProperty(v8::Isolate* isolate, v8::Local context, - v8::Local object, const char* key, - bool* out) { - if (out == nullptr) return false; - - v8::Local value; - if (!object->Get(context, ToV8String(isolate, key)).ToLocal(&value) || - value->IsUndefined() || value->IsNull()) { - return false; - } - - *out = value->BooleanValue(isolate); - return true; -} - static void SetBooleanGlobal(v8::Isolate* isolate, v8::Local context, const char* key, bool value) { context->Global() @@ -165,1324 +78,138 @@ static void SetBooleanGlobal(v8::Isolate* isolate, v8::Local contex .FromMaybe(false); } -static void SetStringGlobal(v8::Isolate* isolate, v8::Local context, - const char* key, const std::string& value) { - context->Global() - ->Set(context, ToV8String(isolate, key), - ToV8String(isolate, value.c_str())) - .FromMaybe(false); -} - -static bool IsSupportedDevSessionPlatform(const std::string& platform) { - // Dev sessions only support the "android" platform identifier. - return platform == "android"; -} - -// Parse an import-map value (a JSON string OR a JS object of shape -// `{ imports: { "": "", ... } }`) into flat (key → URL) entries using -// V8's own JSON/object model. Returns true if it found an `imports` object -// (even if empty); false if the value is unusable. Only flat string key→URL -// mappings are honored; non-string import values are skipped. -static bool ReadImportMapEntries(v8::Isolate* isolate, - v8::Local context, - v8::Local importMapValue, - std::vector>* out) { - v8::Local mapVal = importMapValue; - if (mapVal->IsString()) { - v8::Local parsed; - if (!v8::JSON::Parse(context, mapVal.As()).ToLocal(&parsed)) { - return false; - } - mapVal = parsed; - } - if (!mapVal->IsObject()) return false; - v8::Local mapObj = mapVal.As(); - - v8::Local importsVal; - if (!mapObj->Get(context, ToV8String(isolate, "imports")).ToLocal(&importsVal) || - !importsVal->IsObject()) { - return false; - } - v8::Local imports = importsVal.As(); - v8::Local keys; - if (!imports->GetOwnPropertyNames(context).ToLocal(&keys)) return false; - - for (uint32_t i = 0; i < keys->Length(); ++i) { - v8::Local keyVal; - if (!keys->Get(context, i).ToLocal(&keyVal)) continue; - v8::Local valVal; - if (!imports->Get(context, keyVal).ToLocal(&valVal) || !valVal->IsString()) continue; - v8::String::Utf8Value keyUtf8(isolate, keyVal); - v8::String::Utf8Value valUtf8(isolate, valVal); - if (*keyUtf8 && *valUtf8) { - out->emplace_back(std::string(*keyUtf8), std::string(*valUtf8)); - } - } - return true; -} - -// Apply the v8::Object payload of `__nsConfigureDevRuntime`: re-validate the -// `importMap` shape and read its entries via `ReadImportMapEntries` (V8-based -// parsing) into the process-wide map. Mirrors the import-map handling in -// `ConfigureDevRuntimeCallback`. -static bool ApplyDevRuntimeConfigObject(v8::Isolate* isolate, - v8::Local context, - v8::Local payload, - std::string* errorMessage) { - if (payload.IsEmpty()) { - if (errorMessage != nullptr) { - *errorMessage = "[__nsStartDevSession] runtime config payload must be an object"; - } - return false; - } - - v8::Local importMapValue; - if (!payload->Get(context, ToV8String(isolate, "importMap")).ToLocal(&importMapValue) || - !importMapValue->IsObject()) { - if (errorMessage != nullptr) { - *errorMessage = "[__nsStartDevSession] runtime config payload is missing importMap"; - } - return false; - } - - std::vector> importEntries; - if (!ReadImportMapEntries(isolate, context, importMapValue, &importEntries)) { - if (errorMessage != nullptr) { - *errorMessage = "[__nsStartDevSession] failed to read importMap"; - } - return false; - } - if (importEntries.empty()) { - if (errorMessage != nullptr) { - *errorMessage = "[__nsStartDevSession] runtime config importMap was empty"; - } - return false; - } - SetImportMapEntries(importEntries); - - std::vector patterns; - v8::Local volatilePatternsValue; - if (payload->Get(context, ToV8String(isolate, "volatilePatterns")).ToLocal(&volatilePatternsValue) && - volatilePatternsValue->IsArray()) { - v8::Local arr = volatilePatternsValue.As(); - uint32_t length = arr->Length(); - for (uint32_t i = 0; i < length; ++i) { - v8::Local entry; - if (!arr->Get(context, i).ToLocal(&entry)) continue; - if (!entry->IsString()) continue; - v8::String::Utf8Value utf8(isolate, entry); - if (*utf8 && (*utf8)[0] != '\0') { - patterns.emplace_back(*utf8); - } - } - } - - if (!patterns.empty()) { - SetVolatilePatterns(patterns); - } - - return true; -} - -v8::Local GetOrCreateHotData(v8::Isolate* isolate, const std::string& key) { - auto it = g_hotData.find(key); - if (it != g_hotData.end()) { - if (!it->second.IsEmpty()) { - return it->second.Get(isolate); - } - } - v8::Local obj = v8::Object::New(isolate); - g_hotData[key].Reset(isolate, obj); - return obj; -} - -bool ReadDevSessionConfig(v8::Isolate* isolate, v8::Local context, - v8::Local config, DevSessionState* out, - std::string* errorMessage) { - if (out == nullptr) { - if (errorMessage != nullptr) { - *errorMessage = "[__nsStartDevSession] output session state is required"; - } - return false; - } - - DevSessionState next; - next.active = true; - GetOptionalStringProperty(isolate, context, config, "sessionId", &next.sessionId); - GetOptionalStringProperty(isolate, context, config, "origin", &next.origin); - GetOptionalStringProperty(isolate, context, config, "entryUrl", &next.entryUrl); - GetOptionalStringProperty(isolate, context, config, "clientUrl", &next.clientUrl); - GetOptionalStringProperty(isolate, context, config, "wsUrl", &next.wsUrl); - GetOptionalStringProperty(isolate, context, config, "platform", &next.platform); - GetOptionalStringProperty(isolate, context, config, "runtimeConfigUrl", &next.runtimeConfigUrl); - - v8::Local featuresValue; - if (config->Get(context, ToV8String(isolate, "features")) - .ToLocal(&featuresValue) && - featuresValue->IsObject()) { - v8::Local features = featuresValue.As(); - GetOptionalBooleanProperty(isolate, context, features, "fullReload", - &next.fullReload); - GetOptionalBooleanProperty(isolate, context, features, "cssHmr", - &next.cssHmr); - } - - if (next.sessionId.empty() || next.origin.empty() || next.entryUrl.empty() || - next.clientUrl.empty() || next.wsUrl.empty() || next.platform.empty()) { - if (errorMessage != nullptr) { - *errorMessage = - "[__nsStartDevSession] sessionId, origin, clientUrl, wsUrl, entryUrl, and platform are required"; - } - return false; - } - - if (!IsSupportedDevSessionPlatform(next.platform)) { - if (errorMessage != nullptr) { - *errorMessage = - "[__nsStartDevSession] platform must be android"; - } - return false; - } - - *out = next; - return true; -} - -void ResetActiveDevSession() { - std::lock_guard lock(g_activeDevSessionMutex); - if (IsScriptLoadingLogEnabled() && g_activeDevSession.active) { - DEBUG_WRITE("[dev-session] reset active session=%s started=%s", - g_activeDevSession.sessionId.c_str(), - g_activeDevSession.started ? "true" : "false"); - } - g_activeDevSession = DevSessionState(); -} - -DevSessionState GetActiveDevSessionSnapshot() { - std::lock_guard lock(g_activeDevSessionMutex); - return g_activeDevSession; -} - -void StoreActiveDevSession(const DevSessionState& session) { - std::lock_guard lock(g_activeDevSessionMutex); - g_activeDevSession = session; - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dev-session] stored session=%s started=%s origin=%s client=%s entry=%s", - session.sessionId.c_str(), session.started ? "true" : "false", - session.origin.c_str(), session.clientUrl.c_str(), - session.entryUrl.c_str()); - } -} - -bool HasDevSessionChanged(const DevSessionState& previous, - const DevSessionState& next) { - return !previous.active || previous.sessionId != next.sessionId || - previous.origin != next.origin || previous.entryUrl != next.entryUrl || - previous.clientUrl != next.clientUrl || previous.wsUrl != next.wsUrl || - previous.runtimeConfigUrl != next.runtimeConfigUrl; -} - -std::vector CollectSessionModuleUrls(const DevSessionState& session) { - std::vector invalidate; - if (!session.active || session.origin.empty()) { - return invalidate; - } - - for (const auto& url : tns::GetLoadedModuleUrls()) { - if (!StartsWith(url, session.origin.c_str())) continue; - if (!session.clientUrl.empty() && url == session.clientUrl) continue; - invalidate.push_back(url); - } - - return invalidate; -} - -bool ApplyDevRuntimeConfigFromUrl(const std::string& url, - std::string* errorMessage) { - if (url.empty()) { - return true; - } - - std::string body; - std::string contentType; - int status = 0; - if (!HttpFetchText(url, body, contentType, status) || body.empty()) { - if (errorMessage != nullptr) { - *errorMessage = std::string("[__nsStartDevSession] failed to fetch runtimeConfigUrl: ") + url; - } - return false; - } - - // Parse the JSON response in V8: dev-session bootstrap runs on the JS thread, - // so a live isolate is available. - v8::Isolate* isolate = v8::Isolate::TryGetCurrent(); - if (isolate == nullptr) { - if (errorMessage != nullptr) { - *errorMessage = "[__nsStartDevSession] no current V8 isolate to parse runtime config"; - } - return false; - } - - v8::HandleScope scope(isolate); - v8::Local context = isolate->GetCurrentContext(); - if (context.IsEmpty()) { - if (errorMessage != nullptr) { - *errorMessage = "[__nsStartDevSession] no current V8 context to parse runtime config"; - } - return false; - } - - v8::TryCatch tc(isolate); - v8::Local bodyStr = v8::String::NewFromUtf8( - isolate, body.c_str(), v8::NewStringType::kNormal, - static_cast(body.size())).ToLocalChecked(); - v8::MaybeLocal maybeParsed = v8::JSON::Parse(context, bodyStr); - v8::Local parsed; - if (!maybeParsed.ToLocal(&parsed) || !parsed->IsObject()) { - if (errorMessage != nullptr) { - std::string detail = "unknown runtime config parse error"; - if (tc.HasCaught()) { - v8::String::Utf8Value msg(isolate, tc.Exception()); - if (*msg) detail = *msg; - } - *errorMessage = std::string("[__nsStartDevSession] failed to parse runtime config: ") + detail; - } - return false; - } - - if (!ApplyDevRuntimeConfigObject(isolate, context, parsed.As(), errorMessage)) { - return false; - } - - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dev-session] runtime config applied url=%s", url.c_str()); - } - - return true; -} +// ───────────────────────────────────────────────────────────── +// Dev-boot completion flag +// +// Native-side mirror of `__NS_HMR_BOOT_COMPLETE__`. Read by the kickstart +// pump-wait so its gate is a single relaxed atomic load on the HMR-time +// hot path. The JS dev client flips this via +// `__NS_DEV__.setDevBootComplete(bool)` once the real app root view +// commits; boot orchestration itself is entirely userland. +static std::atomic g_devSessionBootComplete{false}; -// The live "dev-session boot complete" signal is the JS global -// __NS_HMR_BOOT_COMPLETE__, set by ApplyDevSessionGlobals / -// SetDevSessionBootComplete below. (There is no native runloop pump on -// Android — the main NativeScript isolate runs JS on the UI thread.) - -void ApplyDevSessionGlobals(v8::Isolate* isolate, - v8::Local context, - const DevSessionState& session) { - SetStringGlobal(isolate, context, "__NS_HTTP_ORIGIN__", session.origin); - SetStringGlobal(isolate, context, "__NS_HMR_WS_URL__", session.wsUrl); - SetBooleanGlobal(isolate, context, "__NS_HMR_BOOT_COMPLETE__", false); - SetBooleanGlobal(isolate, context, "__NS_HMR_CLIENT_ACTIVE__", false); - SetBooleanGlobal(isolate, context, "__NS_HMR_BROWSER_RUNTIME_CLIENT_ACTIVE__", false); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dev-session] globals applied session=%s origin=%s ws=%s bootComplete=false", - session.sessionId.c_str(), session.origin.c_str(), - session.wsUrl.c_str()); - } +static inline bool IsDevSessionBootComplete() { + return g_devSessionBootComplete.load(std::memory_order_relaxed); } -void SetDevSessionBootComplete(v8::Isolate* isolate, - v8::Local context, - bool value) { +void SetDevBootComplete(v8::Isolate* isolate, v8::Local context, + bool value) { SetBooleanGlobal(isolate, context, "__NS_HMR_BOOT_COMPLETE__", value); + g_devSessionBootComplete.store(value, std::memory_order_relaxed); if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dev-session] __NS_HMR_BOOT_COMPLETE__=%s", - value ? "true" : "false"); - } -} - -void RegisterHotAccept(v8::Isolate* isolate, const std::string& key, v8::Local cb) { - if (cb.IsEmpty()) return; - g_hotAccept[key].emplace_back(v8::Global(isolate, cb)); -} - -void RegisterHotDispose(v8::Isolate* isolate, const std::string& key, v8::Local cb) { - if (cb.IsEmpty()) return; - g_hotDispose[key].emplace_back(v8::Global(isolate, cb)); -} - -void RegisterHotPrune(v8::Isolate* isolate, const std::string& key, v8::Local cb) { - if (cb.IsEmpty()) return; - g_hotPrune[key].emplace_back(v8::Global(isolate, cb)); -} - -std::vector> GetHotAcceptCallbacks(v8::Isolate* isolate, const std::string& key) { - std::vector> out; - auto it = g_hotAccept.find(key); - if (it != g_hotAccept.end()) { - for (auto& gfn : it->second) { - if (!gfn.IsEmpty()) out.push_back(gfn.Get(isolate)); - } - } - return out; -} - -std::vector> GetHotDisposeCallbacks(v8::Isolate* isolate, const std::string& key) { - std::vector> out; - auto it = g_hotDispose.find(key); - if (it != g_hotDispose.end()) { - for (auto& gfn : it->second) { - if (!gfn.IsEmpty()) out.push_back(gfn.Get(isolate)); - } + DEBUG_WRITE("[dev-boot] __NS_HMR_BOOT_COMPLETE__=%s", value ? "true" : "false"); } - return out; -} - -std::vector> GetHotPruneCallbacks(v8::Isolate* isolate, const std::string& key) { - std::vector> out; - auto it = g_hotPrune.find(key); - if (it != g_hotPrune.end()) { - for (auto& gfn : it->second) { - if (!gfn.IsEmpty()) out.push_back(gfn.Get(isolate)); - } - } - return out; } -void RegisterHotEventListener(v8::Isolate* isolate, const std::string& event, v8::Local cb) { - if (cb.IsEmpty()) return; - g_hotEventListeners[event].emplace_back(v8::Global(isolate, cb)); -} +// ───────────────────────────────────────────────────────────── +// HTTP loader helpers -void RemoveHotEventListener(v8::Isolate* isolate, const std::string& event, v8::Local cb) { - if (cb.IsEmpty()) return; - auto it = g_hotEventListeners.find(event); - if (it == g_hotEventListeners.end()) return; - auto& listeners = it->second; - // V8 strict equality — same Function reference. A user that registered - // the same closure twice gets BOTH copies removed; matches - // `EventTarget.removeEventListener` semantics for repeated registrations. - for (auto i = listeners.begin(); i != listeners.end();) { - if (!i->IsEmpty() && i->Get(isolate) == cb) { - i->Reset(); - i = listeners.erase(i); - } else { - ++i; - } +std::string CanonicalizeHttpUrlKey(const std::string& url) { + // Some loaders wrap HTTP module URLs as file://http(s)://... + std::string normalizedUrl = url; + if (StartsWith(normalizedUrl, "file://http://") || StartsWith(normalizedUrl, "file://https://")) { + normalizedUrl = normalizedUrl.substr(strlen("file://")); } - if (listeners.empty()) { - g_hotEventListeners.erase(it); + if (!(StartsWith(normalizedUrl, "http://") || StartsWith(normalizedUrl, "https://"))) { + return normalizedUrl; } -} - -void MarkHotDeclined(const std::string& key) { - if (key.empty()) return; - std::lock_guard lock(g_hotDeclinedMutex); - g_hotDeclined.insert(key); -} - -bool IsHotDeclined(const std::string& key) { - if (key.empty()) return false; - std::lock_guard lock(g_hotDeclinedMutex); - return g_hotDeclined.find(key) != g_hotDeclined.end(); -} + // Drop fragment entirely + size_t hashPos = normalizedUrl.find('#'); + std::string noHash = (hashPos == std::string::npos) ? normalizedUrl : normalizedUrl.substr(0, hashPos); -bool IsAnyModuleDeclined(const std::vector& keys) { - std::lock_guard lock(g_hotDeclinedMutex); - if (g_hotDeclined.empty()) return false; - if (keys.empty()) { - // "Is anything declined?" — yes if the set is non-empty (already - // checked above). - return true; + // Locate path start and query start + size_t schemePos = noHash.find("://"); + if (schemePos == std::string::npos) { + // Unexpected shape; fall back to removing whole query + size_t q = noHash.find('?'); + return (q == std::string::npos) ? noHash : noHash.substr(0, q); } - for (const auto& k : keys) { - if (g_hotDeclined.find(k) != g_hotDeclined.end()) return true; + size_t pathStart = noHash.find('/', schemePos + 3); + if (pathStart == std::string::npos) { + // No path; nothing to normalize + return noHash; } - return false; -} - -std::vector> GetHotEventListeners(v8::Isolate* isolate, const std::string& event) { - std::vector> out; - auto it = g_hotEventListeners.find(event); - if (it != g_hotEventListeners.end()) { - for (auto& gfn : it->second) { - if (!gfn.IsEmpty()) out.push_back(gfn.Get(isolate)); - } - } - return out; -} - -void DispatchHotEvent(v8::Isolate* isolate, v8::Local context, const std::string& event, v8::Local data) { - auto callbacks = GetHotEventListeners(isolate, event); - const bool verbose = tns::IsScriptLoadingLogEnabled(); + size_t qPos = noHash.find('?', pathStart); + std::string originAndPath = (qPos == std::string::npos) ? noHash : noHash.substr(0, qPos); + std::string query = (qPos == std::string::npos) ? std::string() : noHash.substr(qPos + 1); - // Single dispatch loop. Always observe `tryCatch.HasCaught()` and - // `result.ToLocal(...)` for every listener (not just in verbose mode) so the - // dispatcher's behavior never depends on whether logging is enabled. + // IMPORTANT: This function is used as an HTTP module registry/cache key. + // For general-purpose HTTP module loading (public internet), the query string + // can be part of the module's identity (auth, content versioning, routing, etc). + // Therefore we only apply query normalization (sorting/dropping) for known + // NativeScript dev endpoints where `t`/`v`/`import` are purely cache busters. // - // All `DEBUG_WRITE()` calls are gated behind `verbose`, so default dev - // sessions stay quiet; the per-listener counters are cheap and feed a - // verbose-only summary of whether any listener matched — the most useful - // signal during HMR triage (enable with `logScriptLoading: true`). - int matched = 0; // returned undefined OR a truthy non-bool (Promise/object) - int falsey = 0; // returned literal `false` - int threw = 0; // listener threw synchronously - int idx = 0; - for (auto& cb : callbacks) { - v8::TryCatch tryCatch(isolate); - v8::Local args[] = { data }; - v8::MaybeLocal result = cb->Call(context, v8::Undefined(isolate), 1, args); - if (tryCatch.HasCaught()) { - threw++; - if (verbose) { - v8::Local ex = tryCatch.Exception(); - v8::String::Utf8Value m(isolate, ex); - DEBUG_WRITE("[import.meta.hot] Listener #%d for '%s' threw: %s", idx, event.c_str(), *m ? *m : "(unknown)"); - } - } else { - v8::Local ret; - if (result.ToLocal(&ret)) { - if (ret->IsBoolean() && !ret->BooleanValue(isolate)) { - falsey++; - } else { - matched++; - if (verbose && !ret->IsUndefined()) { - v8::String::Utf8Value rstr(isolate, ret); - std::string s = *rstr ? *rstr : "(unknown)"; - DEBUG_WRITE("[import.meta.hot] Listener #%d for '%s' returned: %s", idx, event.c_str(), s.c_str()); - } - } - } - } - idx++; - } - if (verbose) { - DEBUG_WRITE("[import.meta.hot] dispatch summary event='%s' total=%d matched=%d falsey=%d threw=%d", - event.c_str(), (int)callbacks.size(), matched, falsey, threw); - } -} - -void InitializeHotEventDispatcher(v8::Isolate* isolate, v8::Local context) { - using v8::FunctionCallbackInfo; - using v8::Local; - using v8::Value; - - // Create a global function __NS_DISPATCH_HOT_EVENT__(event, data) - // that the HMR client can call to dispatch events to registered listeners. - // Returns the number of listeners that were invoked so callers can detect - // "no-listener" scenarios (which would otherwise look identical to a - // successful dispatch from the JS side). - auto dispatchCb = [](const FunctionCallbackInfo& info) { - v8::Isolate* iso = info.GetIsolate(); - v8::Local ctx = iso->GetCurrentContext(); - - if (info.Length() < 1 || !info[0]->IsString()) { - info.GetReturnValue().Set(v8::Integer::New(iso, -1)); - return; - } - - v8::String::Utf8Value eventName(iso, info[0]); - std::string event = *eventName ? *eventName : ""; - if (event.empty()) { - info.GetReturnValue().Set(v8::Integer::New(iso, -1)); - return; - } - - v8::Local data = info.Length() > 1 ? info[1] : v8::Undefined(iso).As(); - - auto callbacks = GetHotEventListeners(iso, event); - - if (tns::IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[import.meta.hot] Dispatching event '%s' to %d listener(s)", event.c_str(), (int)callbacks.size()); - } - - DispatchHotEvent(iso, ctx, event, data); - info.GetReturnValue().Set(v8::Integer::New(iso, (int)callbacks.size())); - }; - - // __nsListHotEventListeners() — returns an object mapping every registered - // event name to its current listener count. Diagnostic helper for HMR - // dispatch issues so JS code can verify whether a given event has any - // listeners attached at the time of dispatch (the typical failure mode is - // a custom event being dispatched before the user's compiled component - // module has executed its `import.meta.hot.on(...)` registration). - auto listCb = [](const FunctionCallbackInfo& info) { - v8::Isolate* iso = info.GetIsolate(); - v8::Local ctx = iso->GetCurrentContext(); - v8::Local result = v8::Object::New(iso); - for (const auto& kv : g_hotEventListeners) { - v8::Local name = ToV8String(iso, kv.first.c_str()); - v8::Local count = v8::Integer::New(iso, (int)kv.second.size()); - (void)result->CreateDataProperty(ctx, name, count); - } - info.GetReturnValue().Set(result); - }; - - v8::Local global = context->Global(); - v8::Local dispatchFn = v8::Function::New(context, dispatchCb).ToLocalChecked(); - global->CreateDataProperty(context, ToV8String(isolate, "__NS_DISPATCH_HOT_EVENT__"), dispatchFn).Check(); - v8::Local listFn = v8::Function::New(context, listCb).ToLocalChecked(); - global->CreateDataProperty(context, ToV8String(isolate, "__nsListHotEventListeners"), listFn).Check(); -} - -namespace { - -// Shared drainer for the dispose/prune twin runners. Both have identical -// snapshot-and-swap semantics (re-entrancy safety, mid-drain -// re-registration, per-callback try/catch with a script-loading log); the -// only things that differ between them are the registry map they touch -// and the log tag. Extracting the common body keeps any future fix to -// the drain protocol from drifting between the two paths. -// -// `registry` is taken by reference so the caller's file-static map is -// mutated in place. -int DrainHotCallbacks( - v8::Isolate* isolate, v8::Local context, - const std::vector& keys, - std::unordered_map>>& registry, - const char* logTag) { - using v8::Function; - using v8::Global; - using v8::HandleScope; - using v8::Local; - using v8::Object; - using v8::TryCatch; - using v8::Value; - - // Snapshot the keys we'll drain so callers passing an empty list get - // every registered module. We snapshot first (rather than iterating the - // map directly) so the registry can be safely mutated mid-drain — both - // when we erase entries below, and if a callback itself registers a - // new dispose/prune for the same module (legal per Vite spec; lets - // users implement hot-data persistence and re-arm side effects). - std::vector targetKeys; - if (keys.empty()) { - targetKeys.reserve(registry.size()); - for (const auto& kv : registry) { - targetKeys.push_back(kv.first); - } - } else { - targetKeys = keys; - } - - if (targetKeys.empty()) return 0; - - HandleScope handleScope(isolate); - int executed = 0; - - for (const auto& key : targetKeys) { - auto it = registry.find(key); - if (it == registry.end() || it->second.empty()) continue; - - // Move callbacks out of the registry BEFORE invoking. This prevents: - // * Re-entrant drain calls from re-firing the same callbacks. - // * Callbacks that re-register on the same module from racing with - // our iteration — their newly-registered cb lands in the - // now-empty bucket and survives until the next drain (the - // correct Vite-spec behaviour for a module that re-installs - // side-effects after running cleanup). - std::vector> callbacks; - callbacks.swap(it->second); - registry.erase(it); - - // The user-visible callback signature is `(data) => void`. Pass the - // module's `hot.data` so users can stash state across the reload — - // matches Vite's contract documented at: - // https://vite.dev/guide/api-hmr#hot-dispose-cb - // https://vite.dev/guide/api-hmr#hot-prune-cb - Local data = GetOrCreateHotData(isolate, key); - Local args[] = { data }; - - for (auto& gfn : callbacks) { - if (gfn.IsEmpty()) continue; - Local cb = gfn.Get(isolate); - if (cb.IsEmpty()) continue; - - TryCatch tryCatch(isolate); - v8::MaybeLocal result = cb->Call(context, v8::Undefined(isolate), 1, args); - (void)result; - if (tryCatch.HasCaught()) { - // One bad callback must NEVER take down the HMR cycle for - // everyone else. Log under the existing script-loading flag so - // the user has a way to enable diagnostic visibility without - // recompiling, and continue. - if (tns::IsScriptLoadingLogEnabled()) { - Local ex = tryCatch.Exception(); - v8::String::Utf8Value msg(isolate, ex); - DEBUG_WRITE("%s callback threw for key=%s: %s", - logTag, key.c_str(), *msg ? *msg : "(unknown)"); - } - // Don't ReThrow — swallow per-callback failures so subsequent - // drains (and the reboot itself) still run. - continue; - } - ++executed; - } - } - - return executed; -} - -} // namespace - -int RunHotDisposeCallbacks(v8::Isolate* isolate, v8::Local context, - const std::vector& keys) { - return DrainHotCallbacks(isolate, context, keys, g_hotDispose, - "[import.meta.hot.dispose]"); -} - -void InitializeHotDisposeRunner(v8::Isolate* isolate, v8::Local context) { - using v8::FunctionCallbackInfo; - using v8::Local; - using v8::Value; - - // Global JS-callable: `__nsRunHmrDispose(keys?: string[]) => number`. - // Drains `import.meta.hot.dispose` callbacks and returns how many ran. With - // no argument (or a non-array) it drains every registered module; an array of - // keys drains only those modules. - auto runDisposeCb = [](const FunctionCallbackInfo& info) { - v8::Isolate* iso = info.GetIsolate(); - v8::Local ctx = iso->GetCurrentContext(); - - std::vector keys; - if (info.Length() >= 1 && info[0]->IsArray()) { - v8::Local arr = info[0].As(); - uint32_t length = arr->Length(); - keys.reserve(length); - for (uint32_t i = 0; i < length; ++i) { - v8::Local entry; - if (!arr->Get(ctx, i).ToLocal(&entry)) continue; - if (!entry->IsString()) continue; - v8::String::Utf8Value s(iso, entry); - if (*s) keys.emplace_back(*s); - } - } - // info[0] is null/undefined/missing/non-array → empty `keys` → drain all. - - int executed = RunHotDisposeCallbacks(iso, ctx, keys); - info.GetReturnValue().Set(static_cast(executed)); - }; - - v8::Local global = context->Global(); - v8::Local fn = v8::Function::New(context, runDisposeCb).ToLocalChecked(); - global->CreateDataProperty(context, - ToV8String(isolate, "__nsRunHmrDispose"), - fn).Check(); -} - -int RunHotPruneCallbacks(v8::Isolate* isolate, v8::Local context, - const std::vector& keys) { - return DrainHotCallbacks(isolate, context, keys, g_hotPrune, - "[import.meta.hot.prune]"); -} - -void InitializeHotPruneRunner(v8::Isolate* isolate, v8::Local context) { - using v8::FunctionCallbackInfo; - using v8::Local; - using v8::Value; - - // Global JS-callable: `__nsRunHmrPrune(keys?: string[]) => number`. - // Symmetric with `__nsRunHmrDispose`, draining `import.meta.hot.prune` - // callbacks. No argument drains all registered modules; an array of keys - // drains only those. - auto runPruneCb = [](const FunctionCallbackInfo& info) { - v8::Isolate* iso = info.GetIsolate(); - v8::Local ctx = iso->GetCurrentContext(); - - std::vector keys; - if (info.Length() >= 1 && info[0]->IsArray()) { - v8::Local arr = info[0].As(); - uint32_t length = arr->Length(); - keys.reserve(length); - for (uint32_t i = 0; i < length; ++i) { - v8::Local entry; - if (!arr->Get(ctx, i).ToLocal(&entry)) continue; - if (!entry->IsString()) continue; - v8::String::Utf8Value s(iso, entry); - if (*s) keys.emplace_back(*s); - } - } - - int executed = RunHotPruneCallbacks(iso, ctx, keys); - info.GetReturnValue().Set(static_cast(executed)); - }; - - v8::Local global = context->Global(); - v8::Local fn = v8::Function::New(context, runPruneCb).ToLocalChecked(); - global->CreateDataProperty(context, - ToV8String(isolate, "__nsRunHmrPrune"), - fn).Check(); -} - -void InitializeHotDeclinedHelper(v8::Isolate* isolate, v8::Local context) { - using v8::FunctionCallbackInfo; - using v8::Local; - using v8::Value; - - // Global JS-callable: `__nsHasDeclinedModule(keys?: string[]) => boolean`. - // The Angular HMR client passes the eviction-set (`msg.evictPaths`) here - // before applying an update; on `true` it falls back to a full reload via - // `__nsReloadDevApp` instead of the per-cycle reboot. + // The dev server serves every module under ONE canonical URL — module + // identity IS the URL string. Freshness after an HMR edit is handled by + // `__NS_DEV__.invalidateModules` (registry + prefetch-cache evict) plus the + // eviction-driven fetch nonce in `PerformHttpFetchOnceSync`, never by URL + // variation. There is deliberately no path-tag vocabulary to collapse here. // - // No-arg form ("is anything declined at all?") returns `true` if any - // module ever called `import.meta.hot.decline()`. Useful as a coarse - // pre-check: if the answer is `false` the client can skip the more - // expensive per-key check below. - auto hasDeclinedCb = [](const FunctionCallbackInfo& info) { - v8::Isolate* iso = info.GetIsolate(); - v8::Local ctx = iso->GetCurrentContext(); - - std::vector keys; - if (info.Length() >= 1 && info[0]->IsArray()) { - v8::Local arr = info[0].As(); - uint32_t length = arr->Length(); - keys.reserve(length); - for (uint32_t i = 0; i < length; ++i) { - v8::Local entry; - if (!arr->Get(ctx, i).ToLocal(&entry)) continue; - if (!entry->IsString()) continue; - v8::String::Utf8Value s(iso, entry); - if (*s) keys.emplace_back(*s); - } - } - - bool declined = IsAnyModuleDeclined(keys); - info.GetReturnValue().Set(declined); - }; - - v8::Local global = context->Global(); - v8::Local fn = v8::Function::New(context, hasDeclinedCb).ToLocalChecked(); - global->CreateDataProperty(context, - ToV8String(isolate, "__nsHasDeclinedModule"), - fn).Check(); -} - -void InitializeImportMetaHot(v8::Isolate* isolate, - v8::Local context, - v8::Local importMeta, - const std::string& modulePath) { - using v8::Function; - using v8::FunctionCallbackInfo; - using v8::Local; - using v8::Object; - using v8::String; - using v8::Value; - - // Ensure context scope for property creation - v8::HandleScope scope(isolate); - - // Canonicalize key to ensure per-module hot.data persists across HMR URLs. - // Important: this must NOT affect the HTTP loader cache key; otherwise HMR fetches - // can collapse onto an already-evaluated module and no update occurs. - auto canonicalHotKey = [&](const std::string& in) -> std::string { - // Unwrap file://http(s)://... - std::string s = in; - if (StartsWith(s, "file://http://") || StartsWith(s, "file://https://")) { - s = s.substr(strlen("file://")); - } - - const bool isHttpUrl = StartsWith(s, "http://") || StartsWith(s, "https://"); - if (isHttpUrl) { - // Preserve meaningful dev-endpoint query identity (for example /ns/core?p=...) - // while still dropping cache-busters and canonicalizing versioned bridge URLs. - s = CanonicalizeHttpUrlKey(s); - } - - // Drop fragment - size_t hashPos = s.find('#'); - if (hashPos != std::string::npos) s = s.substr(0, hashPos); - - std::string noQuery = s; - std::string suffix; - if (!isHttpUrl) { - size_t qPos = s.find('?'); - noQuery = (qPos == std::string::npos) ? s : s.substr(0, qPos); - } - - // If it's an http(s) URL, normalize only the path portion below. - size_t schemePos = noQuery.find("://"); - size_t pathStart = (schemePos == std::string::npos) ? 0 : noQuery.find('/', schemePos + 3); - if (pathStart == std::string::npos) { - // No path; return without query - return noQuery; - } - - std::string origin = noQuery.substr(0, pathStart); - std::string pathAndSuffix = noQuery.substr(pathStart); - if (isHttpUrl) { - size_t qPos = pathAndSuffix.find('?'); - if (qPos != std::string::npos) { - suffix = pathAndSuffix.substr(qPos); - pathAndSuffix = pathAndSuffix.substr(0, qPos); - } - } - std::string path = pathAndSuffix; - - // Normalize NS HMR virtual module paths: - // /ns/m/__ns_hmr__// -> /ns/m/ - auto normalizeHmrVirtualPath = [&](const char* prefix) { - size_t prefixLen = strlen(prefix); - if (path.compare(0, prefixLen, prefix) != 0) { - return false; - } - - size_t nextSlash = path.find('/', prefixLen); - if (nextSlash == std::string::npos) { - return false; - } - - path = std::string("/ns/m/") + path.substr(nextSlash + 1); - return true; - }; - - // Keep import.meta.hot.data stable across both live-tagged and boot-tagged HMR URLs. - if (!normalizeHmrVirtualPath("/ns/m/__ns_boot__/b1/__ns_hmr__/")) { - normalizeHmrVirtualPath("/ns/m/__ns_hmr__/"); - } - - auto normalizeBridge = [&](const char* needle) { - size_t nlen = strlen(needle); - if (path.compare(0, nlen, needle) != 0) return; - if (path.size() == nlen) return; - if (path.size() <= nlen + 1 || path[nlen] != '/') return; - - size_t i = nlen + 1; - size_t j = i; - while (j < path.size() && std::isdigit(static_cast(path[j]))) { - j++; - } - if (j == i) return; - if (j != path.size()) return; - - path = std::string(needle); - }; - - normalizeBridge("/ns/rt"); - normalizeBridge("/ns/core"); - - // Normalize common script extensions so `/foo` and `/foo.ts` share hot.data. - const char* exts[] = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"}; - for (auto ext : exts) { - if (EndsWith(path, ext)) { - path = path.substr(0, path.size() - strlen(ext)); - break; - } - } - - // Also drop `.vue`? No — SFC endpoints should stay distinct. - return origin + path + suffix; - }; - - const std::string key = canonicalHotKey(modulePath); - if (tns::IsScriptLoadingLogEnabled()) { - bool isReload = (g_hotData.find(key) != g_hotData.end()); - DEBUG_WRITE("[hmr][import.meta.hot] module=%s key=%s isReload=%d", modulePath.c_str(), key.c_str(), isReload); - } - - // Helper to capture key in function data - auto makeKeyData = [&](const std::string& k) -> Local { - return ToV8String(isolate, k.c_str()); - }; - - // accept([deps], cb?) — register cb if provided. The deps array is accepted - // for Vite API compatibility but does not drive selective acceptance. - auto acceptCb = [](const FunctionCallbackInfo& info) { - v8::Isolate* iso = info.GetIsolate(); - Local data = info.Data(); - std::string key; - if (!data.IsEmpty()) { - v8::String::Utf8Value s(iso, data); - key = *s ? *s : ""; - } - v8::Local cb; - if (info.Length() >= 1 && info[0]->IsFunction()) { - cb = info[0].As(); - } else if (info.Length() >= 2 && info[1]->IsFunction()) { - cb = info[1].As(); - } - if (!cb.IsEmpty()) { - RegisterHotAccept(iso, key, cb); - } - // Return undefined - info.GetReturnValue().Set(v8::Undefined(iso)); - }; - - // dispose(cb) — register disposer - auto disposeCb = [](const FunctionCallbackInfo& info) { - v8::Isolate* iso = info.GetIsolate(); - Local data = info.Data(); - std::string key; - if (!data.IsEmpty()) { v8::String::Utf8Value s(iso, data); key = *s ? *s : ""; } - if (info.Length() >= 1 && info[0]->IsFunction()) { - RegisterHotDispose(iso, key, info[0].As()); - } - info.GetReturnValue().Set(v8::Undefined(iso)); - }; - - // prune(cb) — register a callback that fires when this module is removed - // from the dep graph (NOT on every replacement — that's `dispose`). Today - // the NS HMR pipeline does wholesale reboots so prune callbacks rarely - // fire, but the registry is plumbed end-to-end so a future per-module - // HMR client can drain `g_hotPrune` via `__nsRunHmrPrune`. - auto pruneCb = [](const FunctionCallbackInfo& info) { - v8::Isolate* iso = info.GetIsolate(); - Local data = info.Data(); - std::string key; - if (!data.IsEmpty()) { v8::String::Utf8Value s(iso, data); key = *s ? *s : ""; } - if (info.Length() >= 1 && info[0]->IsFunction()) { - RegisterHotPrune(iso, key, info[0].As()); - } - info.GetReturnValue().Set(v8::Undefined(iso)); - }; - - // decline() — mark this module as not hot-updateable (Vite spec). Adds the - // canonical key to `g_hotDeclined`; the HMR client checks this set via - // `__nsHasDeclinedModule(updatedKeys)` before applying an update and - // converts the cycle into a full reload (`__nsReloadDevApp`) on a hit. - auto declineCb = [](const FunctionCallbackInfo& info) { - v8::Isolate* iso = info.GetIsolate(); - Local data = info.Data(); - std::string key; - if (!data.IsEmpty()) { v8::String::Utf8Value s(iso, data); key = *s ? *s : ""; } - if (!key.empty()) { - MarkHotDeclined(key); - if (tns::IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[import.meta.hot.decline] key=%s", key.c_str()); - } - } - info.GetReturnValue().Set(v8::Undefined(iso)); - }; - - // invalidate(message?) — request a full app reload. Per Vite spec this - // notifies the dev server; in NS we short-circuit to the runtime's - // `__nsReloadDevApp` global (which already does the invalidate + re-import - // dance). The optional `message` argument is logged. + // Special cases that LOOK like dev endpoints but aren't normalized: // - // We invoke `__nsReloadDevApp` from a microtask so the user's current - // execution stack (which contains the `invalidate()` call site) finishes - // before the runtime tears down for reload — calling synchronously would - // try to re-bootstrap from inside an in-flight callback. - auto invalidateCb = [](const FunctionCallbackInfo& info) { - v8::Isolate* iso = info.GetIsolate(); - Local data = info.Data(); - std::string key; - if (!data.IsEmpty()) { v8::String::Utf8Value s(iso, data); key = *s ? *s : ""; } - - std::string message; - if (info.Length() >= 1 && info[0]->IsString()) { - v8::String::Utf8Value m(iso, info[0]); - if (*m) message = *m; - } - if (tns::IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[import.meta.hot.invalidate] key=%s message=%s", - key.c_str(), message.empty() ? "(none)" : message.c_str()); - } - - v8::Local ctx = iso->GetCurrentContext(); - v8::Local global = ctx->Global(); - v8::Local reloadVal; - if (!global->Get(ctx, ToV8String(iso, "__nsReloadDevApp")).ToLocal(&reloadVal)) { - info.GetReturnValue().Set(v8::Undefined(iso)); - return; - } - if (!reloadVal->IsFunction()) { - // Older runtime / non-dev mode — silently no-op. Nothing else - // we can usefully do here. - info.GetReturnValue().Set(v8::Undefined(iso)); - return; - } - - // Defer the call via a resolved-promise microtask so we exit the - // current call stack before the reload tears the runtime down. Using - // microtasks rather than `setTimeout` keeps the deferral inside the - // same V8 microtask checkpoint — no event-loop delay, no UI hitch. - v8::Local reloadFn = reloadVal.As(); - v8::Local resolver; - if (v8::Promise::Resolver::New(ctx).ToLocal(&resolver)) { - v8::Local deferred = - v8::Function::New(ctx, [](const FunctionCallbackInfo& innerInfo) { - v8::Isolate* innerIso = innerInfo.GetIsolate(); - v8::Local innerCtx = innerIso->GetCurrentContext(); - v8::Local innerGlobal = innerCtx->Global(); - v8::Local reloadVal; - if (!innerGlobal->Get(innerCtx, ToV8String(innerIso, "__nsReloadDevApp")).ToLocal(&reloadVal)) return; - if (!reloadVal->IsFunction()) return; - v8::Local reloadFn = reloadVal.As(); - v8::TryCatch tc(innerIso); - (void)reloadFn->Call(innerCtx, v8::Undefined(innerIso), 0, nullptr); - // Reload is a fire-and-forget Promise on its own. Per-call - // failures aren't surfaced — they're not actionable from - // user code. - }).ToLocalChecked(); - v8::Local p = resolver->GetPromise(); - v8::MaybeLocal chained = p->Then(ctx, deferred); - (void)chained; - (void)resolver->Resolve(ctx, v8::Undefined(iso)); - } else { - // Promise machinery unavailable — fall back to a synchronous call. - // The user's current call stack will be torn down mid-execution - // but the user already requested a full reload, so that's - // acceptable. - v8::TryCatch tc(iso); - (void)reloadFn->Call(ctx, v8::Undefined(iso), 0, nullptr); - } - - info.GetReturnValue().Set(v8::Undefined(iso)); - }; - - // on(event, cb) — register custom event listener - auto onCb = [](const FunctionCallbackInfo& info) { - v8::Isolate* iso = info.GetIsolate(); - if (info.Length() < 2) { - info.GetReturnValue().Set(v8::Undefined(iso)); - return; - } - if (!info[0]->IsString() || !info[1]->IsFunction()) { - info.GetReturnValue().Set(v8::Undefined(iso)); - return; - } - v8::String::Utf8Value eventName(iso, info[0]); - std::string event = *eventName ? *eventName : ""; - if (!event.empty()) { - RegisterHotEventListener(iso, event, info[1].As()); - } - info.GetReturnValue().Set(v8::Undefined(iso)); - }; - - // off(event, cb) — counterpart to `on`. Removes a previously-registered - // listener (matched by V8 strict equality on the Function reference). - auto offCb = [](const FunctionCallbackInfo& info) { - v8::Isolate* iso = info.GetIsolate(); - if (info.Length() < 2) { - info.GetReturnValue().Set(v8::Undefined(iso)); - return; - } - if (!info[0]->IsString() || !info[1]->IsFunction()) { - info.GetReturnValue().Set(v8::Undefined(iso)); - return; - } - v8::String::Utf8Value eventName(iso, info[0]); - std::string event = *eventName ? *eventName : ""; - if (!event.empty()) { - RemoveHotEventListener(iso, event, info[1].As()); - } - info.GetReturnValue().Set(v8::Undefined(iso)); - }; - - // send(event, data) — send a custom message to the dev server. The runtime - // intentionally does not own a WebSocket; it delegates to a JS-installed - // `globalThis.__nsHmrSendToServer(event, data)` so the WebSocket-owning - // JS layer (typically @nativescript/vite's HMR client) keeps sole - // responsibility for transport. If no JS-side handler is installed (older - // HMR clients, non-dev mode) this is a clean no-op. - auto sendCb = [](const FunctionCallbackInfo& info) { - v8::Isolate* iso = info.GetIsolate(); - v8::Local ctx = iso->GetCurrentContext(); - v8::Local global = ctx->Global(); - v8::Local handlerVal; - if (!global->Get(ctx, ToV8String(iso, "__nsHmrSendToServer")).ToLocal(&handlerVal)) { - info.GetReturnValue().Set(v8::Undefined(iso)); - return; - } - if (!handlerVal->IsFunction()) { - info.GetReturnValue().Set(v8::Undefined(iso)); - return; + // `/@ng/component` (Angular HMR component-update endpoint) + // The `t` (timestamp) parameter is the WHOLE POINT of the URL — it + // identifies a specific recompile of the component's metadata after + // a `.html`/style edit. Stripping it would collapse every HMR fetch + // to the same cache key (the boot-time call uses `Date.now()` and + // each subsequent save uses a new `Date.now()`), and the second + // `__ns_import(...)` would hit V8's module cache, resolve the + // boot-time `_UpdateMetadata` default export, and call + // `ɵɵreplaceMetadata` with stale instructions. Result: server logs + // `(client) hmr update`, the listener fires, but the visual never + // changes because the runtime swapped the live view's metadata + // with the same metadata it already had. Treat the path as a + // non-dev endpoint and preserve the query verbatim so each + // timestamped fetch is a distinct registry entry. + // + // Apply the special-case check BEFORE the dev-endpoint short-circuit so + // it covers paths under `/ns/m//@ng/component` (the + // resolved URL Angular's compiler produces relative to the component's + // `import.meta.url`). + { + std::string pathOnly = originAndPath.substr(pathStart); + if (pathOnly.find("/@ng/component") != std::string::npos) { + // Preserve query as-is — `t` is the version discriminator. + return noHash; } - v8::Local handler = handlerVal.As(); - - // Forward `(event, data)` exactly as called. We don't enforce types on - // `event` (Vite spec only specifies the first arg as a string but - // implementations let it be coerced) and we pass `data` through - // verbatim — JS-side serialization is the transport's concern. - int argc = info.Length(); - if (argc > 2) argc = 2; - std::vector> args; - args.reserve(argc); - for (int i = 0; i < argc; ++i) args.push_back(info[i]); - - v8::TryCatch tc(iso); - (void)handler->Call(ctx, v8::Undefined(iso), argc, args.data()); - if (tc.HasCaught() && tns::IsScriptLoadingLogEnabled()) { - v8::Local ex = tc.Exception(); - v8::String::Utf8Value m(iso, ex); - DEBUG_WRITE("[import.meta.hot.send] handler threw: %s", *m ? *m : "(unknown)"); + const bool isDevEndpoint = + StartsWith(pathOnly, "/ns/") || + StartsWith(pathOnly, "/node_modules/.vite/") || + StartsWith(pathOnly, "/@id/") || + StartsWith(pathOnly, "/@fs/"); + if (!isDevEndpoint) { + // Preserve query as-is (fragment already removed). + return noHash; } - info.GetReturnValue().Set(v8::Undefined(iso)); - }; - - Local hot = Object::New(isolate); - // Stable flags - hot->CreateDataProperty(context, ToV8String(isolate, "data"), - GetOrCreateHotData(isolate, key)).Check(); - // Methods - hot->CreateDataProperty( - context, ToV8String(isolate, "accept"), - v8::Function::New(context, acceptCb, makeKeyData(key)).ToLocalChecked()).Check(); - hot->CreateDataProperty( - context, ToV8String(isolate, "dispose"), - v8::Function::New(context, disposeCb, makeKeyData(key)).ToLocalChecked()).Check(); - hot->CreateDataProperty( - context, ToV8String(isolate, "prune"), - v8::Function::New(context, pruneCb, makeKeyData(key)).ToLocalChecked()).Check(); - hot->CreateDataProperty( - context, ToV8String(isolate, "decline"), - v8::Function::New(context, declineCb, makeKeyData(key)).ToLocalChecked()).Check(); - hot->CreateDataProperty( - context, ToV8String(isolate, "invalidate"), - v8::Function::New(context, invalidateCb, makeKeyData(key)).ToLocalChecked()).Check(); - hot->CreateDataProperty( - context, ToV8String(isolate, "on"), - v8::Function::New(context, onCb, makeKeyData(key)).ToLocalChecked()).Check(); - hot->CreateDataProperty( - context, ToV8String(isolate, "off"), - v8::Function::New(context, offCb, makeKeyData(key)).ToLocalChecked()).Check(); - hot->CreateDataProperty( - context, ToV8String(isolate, "send"), - v8::Function::New(context, sendCb, makeKeyData(key)).ToLocalChecked()).Check(); - - // Attach to import.meta - importMeta->CreateDataProperty( - context, ToV8String(isolate, "hot"), - hot).Check(); -} - -// ───────────────────────────────────────────────────────────── -// HTTP loader helpers + speculative module prefetcher. -// -// The cache lives in `g_prefetchCache`, populated by background fetch -// threads (`std::thread` + `std::condition_variable` for concurrency -// gating). `HttpFetchText` checks the cache first (a "destructive read": -// consumed entries are erased) and only falls through to a fresh JNI -// HTTP fetch on a miss. -// -// Two flavours of kickstart drive the cache: -// - `KickstartHmrPrefetchSync(seed)` — cold-boot BFS over static -// imports, recursively widening from a seed URL until the wave -// drains or `timeoutSeconds` elapses. -// - `KickstartHmrPrefetchUrlsSync(urls)` — HMR-driven parallel -// fetch for a precomputed inverse-dep closure (e.g. `evictPaths` -// from a dev-server save message). No graph walk: server already -// told us the exact set to refresh. -// -// `RegisterHttpFetchYield` exposes a pluggable "yield to host" hook -// called from inside `KickstartRunSync`'s wait loop. The default is a no-op; -// embedders can install their own pump to keep the UI responsive. - -namespace { - -// Cap how many import specifiers we honour per module on the BFS, and -// how large a body we'll scan at all. Pretty-printed bundler output -// can easily blow past both — at that point we're better off paying -// the network on demand than parsing the giant string twice. -constexpr size_t kPrefetchMaxImportsPerModule = 256; -constexpr size_t kPrefetchMaxScanBytes = 2 * 1024 * 1024; // 2 MiB - -std::mutex g_prefetchMutex; -auto* _g_prefetchCache = new std::unordered_map(); -auto& g_prefetchCache = *_g_prefetchCache; - -inline bool IsHorizontalWs(char c) { return c == ' ' || c == '\t'; } -inline bool IsIdentifierChar(unsigned char c) { - return std::isalnum(c) || c == '_' || c == '$'; -} -inline char PreviousNonHwsChar(const std::string& s, size_t pos) { - if (pos == 0) return 0; - ssize_t i = static_cast(pos) - 1; - while (i >= 0 && IsHorizontalWs(s[i])) --i; - if (i < 0) return 0; - return s[i]; -} - -bool LooksLikeJsSourceUrl(const std::string& url) { - size_t qpos = url.find('?'); - std::string path = (qpos == std::string::npos) ? url : url.substr(0, qpos); - // Block clearly non-JS content; on cache hit V8 would attempt to compile - // CSS/images/etc. as ES modules and fail in confusing ways. - if (tns::EndsWith(path, ".css") || tns::EndsWith(path, ".scss") || - tns::EndsWith(path, ".sass") || tns::EndsWith(path, ".less")) return false; - if (tns::EndsWith(path, ".png") || tns::EndsWith(path, ".jpg") || - tns::EndsWith(path, ".jpeg") || tns::EndsWith(path, ".gif") || - tns::EndsWith(path, ".svg") || tns::EndsWith(path, ".webp") || - tns::EndsWith(path, ".ico")) return false; - if (tns::EndsWith(path, ".json")) return false; - if (tns::EndsWith(path, ".html") || tns::EndsWith(path, ".htm")) return false; - if (tns::EndsWith(path, ".woff") || tns::EndsWith(path, ".woff2") || - tns::EndsWith(path, ".ttf") || tns::EndsWith(path, ".otf") || - tns::EndsWith(path, ".eot")) return false; - if (tns::EndsWith(path, ".mp4") || tns::EndsWith(path, ".webm") || - tns::EndsWith(path, ".mp3") || tns::EndsWith(path, ".wav")) return false; - return true; -} + } -// Two-pass scan over a module body to extract its static-import URLs: -// Pass 1: `... from ""` (covers all import-from forms, including -// default, namespace, named, side-effect re-exports). -// Pass 2: `import ""` (side-effect imports). -// Dynamic imports (`import(…)`) and `.from(…)` member access are -// explicitly rejected — accepting them would feed us too many false -// positives that dilute the BFS budget. -std::vector ScanStaticImportSpecifiers(const std::string& source, size_t maxResults) { - std::vector result; - if (source.size() > kPrefetchMaxScanBytes) return result; - std::unordered_set seen; - result.reserve(16); - - auto captureSpecAfter = [&](size_t cursor) -> ssize_t { - while (cursor < source.size()) { - char c = source[cursor]; - if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { cursor++; continue; } - break; - } - if (cursor >= source.size()) return -1; - char quote = source[cursor]; - if (quote != '"' && quote != '\'' && quote != '`') return -1; - size_t end = source.find(quote, cursor + 1); - if (end == std::string::npos) return -1; - std::string spec = source.substr(cursor + 1, end - cursor - 1); - if (!spec.empty() && spec.find('\n') == std::string::npos && seen.insert(spec).second) { - result.push_back(std::move(spec)); - } - return static_cast(end + 1); - }; + if (query.empty()) return originAndPath; - { - const char* needle = "from"; - const size_t needleLen = 4; - size_t pos = 0; - while (pos < source.size() && result.size() < maxResults) { - size_t hit = source.find(needle, pos); - if (hit == std::string::npos) break; - if (hit > 0 && IsIdentifierChar(static_cast(source[hit - 1]))) { pos = hit + 1; continue; } - size_t after = hit + needleLen; - if (after < source.size() && IsIdentifierChar(static_cast(source[after]))) { pos = hit + 1; continue; } - char prev = PreviousNonHwsChar(source, hit); - bool ok = (prev == '}' || prev == '*' || prev == ',' || - IsIdentifierChar(static_cast(prev))); - if (!ok) { pos = hit + 1; continue; } - ssize_t adv = captureSpecAfter(after); - if (adv < 0) { pos = hit + 1; continue; } - pos = static_cast(adv); + // Keep all params except typical import markers or t/v cache busters; sort for stability. + std::vector kept; + size_t start = 0; + while (start <= query.size()) { + size_t amp = query.find('&', start); + std::string pair = (amp == std::string::npos) ? query.substr(start) : query.substr(start, amp - start); + if (!pair.empty()) { + size_t eq = pair.find('='); + std::string name = (eq == std::string::npos) ? pair : pair.substr(0, eq); + // Drop import marker and common cache-busting stamps. + if (!(name == "import" || name == "t" || name == "v")) kept.push_back(pair); } + if (amp == std::string::npos) break; + start = amp + 1; } - { - const char* needle = "import"; - const size_t needleLen = 6; - size_t pos = 0; - while (pos < source.size() && result.size() < maxResults) { - size_t hit = source.find(needle, pos); - if (hit == std::string::npos) break; - if (hit > 0 && IsIdentifierChar(static_cast(source[hit - 1]))) { pos = hit + 1; continue; } - size_t after = hit + needleLen; - if (after < source.size() && IsIdentifierChar(static_cast(source[after]))) { pos = hit + 1; continue; } - char prev = PreviousNonHwsChar(source, hit); - bool atStmtStart = (prev == 0 || prev == '\n' || prev == '\r' || prev == ';' || prev == '}'); - if (!atStmtStart) { pos = hit + 1; continue; } - size_t cursor = after; - while (cursor < source.size() && IsHorizontalWs(source[cursor])) cursor++; - if (cursor >= source.size()) break; - char next = source[cursor]; - if (next == '(') { pos = hit + 1; continue; } - if (next != '"' && next != '\'' && next != '`') { pos = hit + 1; continue; } - ssize_t adv = captureSpecAfter(cursor); - if (adv < 0) { pos = hit + 1; continue; } - pos = static_cast(adv); - } + if (kept.empty()) return originAndPath; + std::sort(kept.begin(), kept.end()); + std::string rebuilt = originAndPath + "?"; + for (size_t i = 0; i < kept.size(); i++) { + if (i > 0) rebuilt += "&"; + rebuilt += kept[i]; } - return result; + return rebuilt; } -} // anonymous namespace - // Resolve a relative/root-absolute import specifier against a parent URL // using plain string manipulation. Only relative (`./`, `../`) and // root-absolute (`/`) specifiers are resolved here; bare specifiers and @@ -1493,14 +220,14 @@ std::string ResolveImportSpecifierAgainstUrl(const std::string& specifier, const std::string& parentUrl) { if (specifier.empty()) return ""; // Already absolute. - if (tns::StartsWith(specifier, "http://") || tns::StartsWith(specifier, "https://")) { + if (StartsWith(specifier, "http://") || StartsWith(specifier, "https://")) { return specifier; } - bool isRelative = tns::StartsWith(specifier, "./") || tns::StartsWith(specifier, "../"); + bool isRelative = StartsWith(specifier, "./") || StartsWith(specifier, "../"); bool isRootAbs = !specifier.empty() && specifier[0] == '/'; if (!isRelative && !isRootAbs) return ""; - if (!(tns::StartsWith(parentUrl, "http://") || tns::StartsWith(parentUrl, "https://"))) { + if (!(StartsWith(parentUrl, "http://") || StartsWith(parentUrl, "https://"))) { return ""; } // Drop fragment + query from parent. @@ -1557,8 +284,112 @@ std::string ResolveImportSpecifierAgainstUrl(const std::string& specifier, return origin + norm + suffix; } +// ───────────────────────────────────────────────────────────── +// Eviction-driven fetch cache-bust +// +// When the HMR client invalidates a module, the NEXT network fetch of +// that module must not be satisfiable by any HTTP cache layer between +// the runtime and the dev server (OS URL caches, proxies, a +// host-installed HttpResponseCache). `InvalidateModules` marks the +// canonical keys of the eviction set here; `PerformHttpFetchOnceSync` +// then appends a unique `__ns_dev_nonce` query parameter to the +// wire-level request for any marked URL, guaranteeing the cache sees +// a URL it has never stored. The nonce is transport-only — it never +// enters the module registry key (identity stays the canonical URL), +// and the server and the registry never see a varied URL. +static std::mutex g_bustNextFetchMutex; +static std::unordered_set g_bustNextFetchKeys; + +void MarkUrlsForCacheBust(const std::vector& urls) { + if (urls.empty()) return; + std::lock_guard lock(g_bustNextFetchMutex); + for (const auto& url : urls) { + if (url.empty()) continue; + if (!(StartsWith(url, "http://") || StartsWith(url, "https://"))) continue; + g_bustNextFetchKeys.insert(CanonicalizeHttpUrlKey(url)); + } +} + +// Peek (do not consume) — the fetch may be retried on transient failure +// and the retry must still carry a nonce. Cleared on fetch success. +static bool IsUrlMarkedForCacheBust(const std::string& url) { + std::lock_guard lock(g_bustNextFetchMutex); + if (g_bustNextFetchKeys.empty()) return false; + return g_bustNextFetchKeys.find(CanonicalizeHttpUrlKey(url)) != g_bustNextFetchKeys.end(); +} + +static void ClearCacheBustForUrl(const std::string& url) { + std::lock_guard lock(g_bustNextFetchMutex); + if (g_bustNextFetchKeys.empty()) return; + g_bustNextFetchKeys.erase(CanonicalizeHttpUrlKey(url)); +} + +static void ClearAllCacheBustMarks() { + std::lock_guard lock(g_bustNextFetchMutex); + g_bustNextFetchKeys.clear(); +} + +// ============================================================================ +// HTTP body cache + parallel kickstart prewarm +// ============================================================================ +// +// V8 only exposes a synchronous ResolveModuleCallback for static imports. +// Each call into HttpFetchText() blocks the JS thread on a synchronous +// network turn, which forces serial fetching from the JS thread's +// perspective. +// +// `__NS_DEV__.kickstartPrefetch(urls)` lets the JS dev client hand the +// runtime a server-computed module closure (cold-boot graph or HMR +// eviction set) to fetch in one parallel wave BEFORE V8 walks the import +// graph. Bodies land in `g_prefetchCache` keyed by full URL; the +// always-on cache read in `HttpFetchText` then serves V8's synchronous +// walk at memory speed. +// +// The runtime performs NO import scanning and NO speculative graph +// discovery of its own — the server owns the module graph and supplies +// explicit URL lists. +// +// Correctness invariants: +// 1. Cache reads consume (one-shot). A second HttpFetchText for the +// same URL after a cache hit triggers a fresh network fetch — this +// is the right behavior for HMR where re-fetching means we got a +// newer version of the module. +// 2. Every kickstart fetch goes through IsRemoteUrlAllowed() exactly +// the same way HttpFetchText does. The security gate is preserved. +// 3. Kickstart overwrites cache entries unconditionally — a body the +// client explicitly asked to re-fetch is authoritative by +// construction (the previous entry is stale). + namespace { +std::mutex g_prefetchMutex; +// Heap-allocated (leaky singleton) to prevent V8 crash during +// __cxa_finalize_ranges. See g_moduleRegistry comment in +// ModuleInternalCallbacks.cpp for full rationale. +auto* _g_prefetchCache = new std::unordered_map(); +auto& g_prefetchCache = *_g_prefetchCache; + +bool LooksLikeJsSourceUrl(const std::string& url) { + size_t qpos = url.find('?'); + std::string path = (qpos == std::string::npos) ? url : url.substr(0, qpos); + // Block clearly non-JS content; on cache hit V8 would attempt to compile + // CSS/images/etc. as ES modules and fail in confusing ways. + if (tns::EndsWith(path, ".css") || tns::EndsWith(path, ".scss") || + tns::EndsWith(path, ".sass") || tns::EndsWith(path, ".less")) return false; + if (tns::EndsWith(path, ".png") || tns::EndsWith(path, ".jpg") || + tns::EndsWith(path, ".jpeg") || tns::EndsWith(path, ".gif") || + tns::EndsWith(path, ".svg") || tns::EndsWith(path, ".webp") || + tns::EndsWith(path, ".ico")) return false; + if (tns::EndsWith(path, ".json")) return false; + if (tns::EndsWith(path, ".html") || tns::EndsWith(path, ".htm")) return false; + if (tns::EndsWith(path, ".woff") || tns::EndsWith(path, ".woff2") || + tns::EndsWith(path, ".ttf") || tns::EndsWith(path, ".otf") || + tns::EndsWith(path, ".eot")) return false; + if (tns::EndsWith(path, ".mp4") || tns::EndsWith(path, ".webm") || + tns::EndsWith(path, ".mp3") || tns::EndsWith(path, ".wav")) return false; + return true; +} + // Pluggable host yield. Default: no-op. Embedders that want a JS-thread // runloop pump during cold-boot fetches can install one via // `RegisterHttpFetchYield` (e.g. ALooper_pollOnce(0)). @@ -1581,96 +412,23 @@ void ClearHttpModulePrefetchCache() { g_prefetchCache.clear(); } +// Drop a specific URL set from `g_prefetchCache`. Used by +// `InvalidateModules` so an HMR eviction purges any stale HTTP body +// the previous kickstart wave left behind. See the doc comment in +// HMRSupport.h for the cache-poisoning case this fixes. void EvictHttpModulePrefetchCacheUrls(const std::vector& urls) { if (urls.empty()) return; std::lock_guard lock(g_prefetchMutex); size_t hits = 0; for (const std::string& u : urls) { - auto it = g_prefetchCache.find(u); - if (it != g_prefetchCache.end()) { g_prefetchCache.erase(it); ++hits; } - } - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[prefetch][evict] urls=%lu hits=%lu remaining=%lu", - (unsigned long)urls.size(), (unsigned long)hits, - (unsigned long)g_prefetchCache.size()); - } -} - -// ───────────────────────────────────────────────────────────── -// HTTP loader helpers (the speculative-prefetcher additions live above). - -// Drop fragments and normalize parameters for consistent registry keys. -std::string CanonicalizeHttpUrlKey(const std::string& url) { - // Some loaders wrap HTTP module URLs as file://http(s)://... - std::string normalizedUrl = url; - if (StartsWith(normalizedUrl, "file://http://") || StartsWith(normalizedUrl, "file://https://")) { - normalizedUrl = normalizedUrl.substr(strlen("file://")); - } - if (!(StartsWith(normalizedUrl, "http://") || StartsWith(normalizedUrl, "https://"))) { - return normalizedUrl; - } - // Remove fragment - size_t hashPos = normalizedUrl.find('#'); - std::string noHash = (hashPos == std::string::npos) ? normalizedUrl : normalizedUrl.substr(0, hashPos); - - // Split into origin+path and query - size_t qPos = noHash.find('?'); - std::string originAndPath = (qPos == std::string::npos) ? noHash : noHash.substr(0, qPos); - std::string query = (qPos == std::string::npos) ? std::string() : noHash.substr(qPos + 1); - - // Normalize bridge endpoints to keep a single realm across HMR updates: - // - /ns/rt/ -> /ns/rt - // - /ns/core/ -> /ns/core - size_t schemePos = originAndPath.find("://"); - if (schemePos != std::string::npos) { - size_t pathStart = originAndPath.find('/', schemePos + 3); - if (pathStart != std::string::npos) { - std::string pathOnly = originAndPath.substr(pathStart); - auto normalizeBridge = [&](const char* needle) { - size_t nlen = strlen(needle); - if (pathOnly.size() <= nlen) return false; - if (pathOnly.compare(0, nlen, needle) != 0) return false; - if (pathOnly.size() == nlen) return true; - if (pathOnly[nlen] != '/') return false; - size_t i = nlen + 1; - size_t j = i; - while (j < pathOnly.size() && isdigit((unsigned char)pathOnly[j])) j++; - // Only normalize exact version segment: /ns/*/ (no further segments) - if (j == i) return false; - if (j != pathOnly.size()) return false; - originAndPath = originAndPath.substr(0, pathStart) + std::string(needle); - return true; - }; - if (!normalizeBridge("/ns/rt")) { - normalizeBridge("/ns/core"); - } - } - } - - if (query.empty()) return originAndPath; - - // Strip ?import markers and sort remaining query params for stability - std::vector kept; - size_t start = 0; - while (start <= query.size()) { - size_t amp = query.find('&', start); - std::string pair = (amp == std::string::npos) ? query.substr(start) : query.substr(start, amp - start); - if (!pair.empty()) { - size_t eq = pair.find('='); - std::string name = (eq == std::string::npos) ? pair : pair.substr(0, eq); - if (!(name == "import")) kept.push_back(pair); - } - if (amp == std::string::npos) break; - start = amp + 1; + auto it = g_prefetchCache.find(u); + if (it != g_prefetchCache.end()) { g_prefetchCache.erase(it); ++hits; } } - if (kept.empty()) return originAndPath; - std::sort(kept.begin(), kept.end()); - std::string rebuilt = originAndPath + "?"; - for (size_t i = 0; i < kept.size(); i++) { - if (i > 0) rebuilt += "&"; - rebuilt += kept[i]; + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[prefetch][evict] urls=%lu hits=%lu remaining=%lu", + (unsigned long)urls.size(), (unsigned long)hits, + (unsigned long)g_prefetchCache.size()); } - return rebuilt; } // Thread-local capture of the most recent JNI-level fetch failure @@ -1739,9 +497,9 @@ static bool IsRetryableFetchReason(const std::string& reason) { } // Raw JNI fetch — no cache lookup, no allowlist gate. Used by the -// background prefetch threads (which already pre-filtered URLs) so the -// public `HttpFetchText` can keep its allowlist-and-cache logic in one -// place without recursing into itself. Returns true on success (2xx, +// kickstart threads (which already pre-filtered URLs) so the public +// `HttpFetchText` can keep its allowlist-and-cache logic in one place +// without recursing into itself. Returns true on success (2xx, // non-empty body). static bool PerformHttpFetchOnceSync(const std::string& url, std::string& out, @@ -1811,17 +569,15 @@ bool HttpFetchText(const std::string& url, std::string& out, std::string& conten return false; } - // Speculative-prefetch cache check (destructive read). + // Cache-read fast path. The JS dev client populates `g_prefetchCache` + // via `__NS_DEV__.kickstartPrefetch(urls)` right before importing (cold + // boot) or re-importing (HMR); by the time V8's synchronous walk asks + // for a module, the body is already here and the walk runs at memory + // speed instead of network speed. // - // Honoured only when the opt-in prefetcher is enabled (package.json - // "httpModulePrefetch", default false). When disabled, the prefetch wave - // never populates the cache (see KickstartHmrPrefetch*Sync) AND this read - // is skipped, restoring the pre-prefetcher fetch behavior bit-for-bit. - // Volatility is enforced upstream by `EvictHttpModulePrefetchCacheUrls` on - // the eviction set rather than by gating reads here. Consuming the entry on - // hit guarantees that a re-fetch after HMR goes back to the network for - // fresh source. - if (IsHttpModulePrefetchEnabled()) { + // Cache reads are one-shot; consuming the entry guarantees that a + // re-fetch (e.g. after HMR) goes back to the network for fresh source. + { std::string cached; bool cacheHit = false; { @@ -1870,6 +626,9 @@ bool HttpFetchText(const std::string& url, std::string& out, std::string& conten if (attempt > 1 && IsScriptLoadingLogEnabled()) { DEBUG_WRITE("[http-esm][fetch][retry-ok] url=%s attempt=%d", url.c_str(), attempt); } + // Yield to the host after the sync fetch block so any installed + // pump can repaint before V8 calls us again. + InvokeHttpFetchYield(); return true; } std::string reason = TakeLastHttpFetchErrorReason(); @@ -1892,10 +651,9 @@ bool HttpFetchText(const std::string& url, std::string& out, std::string& conten return false; } -// True raw HTTP fetch path. See PerformHttpFetchOnceSync forward -// declaration above for purpose. We extracted this from HttpFetchText -// so the prefetcher (which already filtered URLs and intends to -// populate the cache) doesn't re-check the cache itself. +// True raw HTTP fetch path. Kept separate from HttpFetchText so the +// kickstart (which already filtered URLs and intends to populate the +// cache) doesn't re-check the cache itself. static bool PerformHttpFetchOnceSync(const std::string& url, std::string& out, std::string& contentType, @@ -1909,6 +667,30 @@ static bool PerformHttpFetchOnceSync(const std::string& url, if (IsScriptLoadingLogEnabled()) { DEBUG_WRITE("[http-esm][fetch][enter] url=%s", url.c_str()); } + + // Eviction-driven cache-bust: if this URL's canonical key was marked + // by `InvalidateModules` (via `MarkUrlsForCacheBust`), append a + // unique nonce query parameter so any HTTP cache layer sees a URL it + // has never stored and must go to origin. The dev server ignores + // unknown query params on module routes, so the response body is + // unchanged. First-touch fetches don't need busting — nothing has + // cached them yet — so unmarked URLs go out verbatim (some Vite + // virtual routes require exact-match URLs and 404 on unknown query + // params). + std::string fetchUrl = url; + const bool bustRequested = IsUrlMarkedForCacheBust(url); + if (bustRequested) { + static std::atomic s_fetchSeq{0}; + const uint64_t seq = s_fetchSeq.fetch_add(1, std::memory_order_relaxed); + const uint64_t nowMs = (uint64_t)std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + fetchUrl += (url.find('?') == std::string::npos) ? '?' : '&'; + fetchUrl += "__ns_dev_nonce="; + fetchUrl += std::to_string(nowMs); + fetchUrl += "-"; + fetchUrl += std::to_string(seq); + } + try { JEnv env; @@ -1975,7 +757,7 @@ static bool PerformHttpFetchOnceSync(const std::string& url, if (!clsURL) return false; jmethodID urlCtor = env.GetMethodID(clsURL, "", "(Ljava/lang/String;)V"); jmethodID openConnection = env.GetMethodID(clsURL, "openConnection", "()Ljava/net/URLConnection;"); - jstring jUrlStr = env.NewStringUTF(url.c_str()); + jstring jUrlStr = env.NewStringUTF(fetchUrl.c_str()); jobject urlObj = env.NewObject(clsURL, urlCtor, jUrlStr); // `URL` ctor throws MalformedURLException on bad input. Drain it @@ -2114,7 +896,15 @@ static bool PerformHttpFetchOnceSync(const std::string& url, } if (status == 0) status = 200; // assume OK if not HTTP - return status >= 200 && status < 300 && !out.empty(); + bool ok = status >= 200 && status < 300 && !out.empty(); + // A fresh body arrived from origin — the bust request (if any) has + // been satisfied. Clear the mark so steady-state re-fetches of the + // same URL don't keep paying the nonce (and stay exact-match for + // routes that require it). + if (ok && bustRequested) { + ClearCacheBustForUrl(url); + } + return ok; } catch (NativeScriptException& nse) { // `JEnv::CheckForJavaException()` converts any pending Java // exception into a `NativeScriptException` and rethrows on the @@ -2154,14 +944,16 @@ static bool PerformHttpFetchOnceSync(const std::string& url, } // ───────────────────────────────────────────────────────────── -// Kickstart prefetcher. +// List-mode kickstart prewarm. // -// `KickstartHmrPrefetchSync` does seed-rooted BFS; `KickstartHmrPrefetchUrlsSync` -// runs a parallel fetch wave over a pre-computed URL list. Both funnel -// through `KickstartRunSync` so the wait loop / metrics / logging are -// shared. Concurrency is bounded by a counting semaphore implemented -// with mutex + condition variable (NDKs do not yet ship the C++20 -// `std::counting_semaphore`). +// The dev server owns the module graph: it computes the inverse-dep +// closure for HMR updates (`evictPaths`) and can crawl the entry graph +// for cold boot. The client hands that explicit URL list to +// `__NS_DEV__.kickstartPrefetch(urls)`, which fetches every entry in one +// parallel wave into `g_prefetchCache` before V8 starts its serial +// synchronous walk. Concurrency is bounded by a counting semaphore +// implemented with mutex + condition variable (NDKs do not yet ship the +// C++20 `std::counting_semaphore`). namespace { @@ -2193,7 +985,6 @@ struct KickstartContext { std::atomic fetchedCount{0}; std::atomic bytes{0}; std::unique_ptr concurrency; - bool recursive = true; // Outstanding-work counter: each scheduled fetch increments + decrements a // counter under a mutex, and the wait loop blocks on `cv.wait_for` for @@ -2236,66 +1027,36 @@ void KickstartScheduleUrls(std::shared_ptr ctx, } if (!fresh) continue; - // In recursive (cold-boot BFS) mode, skip URLs already in the cache. - // In HMR mode (recursive=false) the caller has *explicitly* listed - // URLs to refresh — honoring an existing cache entry would feed V8 - // the stale body. So we skip this short-circuit when recursive=false. - if (ctx->recursive) { - std::lock_guard lock(g_prefetchMutex); - if (g_prefetchCache.find(urlRef) != g_prefetchCache.end()) continue; - } + // No "already cached" short-circuit here — the caller has explicitly + // told us "fetch these URLs fresh". Any body sitting in + // `g_prefetchCache` for one of them is a leftover from a previous + // wave that V8 didn't consume; honoring it would feed V8 a stale + // body on the next walk — the "1 cycle behind" symptom for `.ts` + // edits with many transitive importers. (`InvalidateModules` + // pre-clears the cache for the eviction set, so this is + // defense-in-depth — but the kickstart may also be invoked + // manually for diagnostics, and we want it to be correct in + // isolation.) ctx->EnterPending(); std::string urlCopy = urlRef; - const bool hmrMode = !ctx->recursive; auto ctxCopy = ctx; - std::thread([ctxCopy, urlCopy, hmrMode]() { + std::thread([ctxCopy, urlCopy]() { ctxCopy->concurrency->Acquire(); std::string body, contentType; int status = 0; bool ok = PerformHttpFetchOnceSync(urlCopy, body, contentType, status); if (ok && status >= 200 && status < 300 && !body.empty()) { - size_t bodySize = body.size(); - std::string scanSource; + const size_t bodySize = body.size(); + // Overwrite unconditionally — the fresh body we just fetched is + // by definition the authoritative copy; any older cache entry is + // stale by construction (the caller has just told us so). { std::lock_guard lock(g_prefetchMutex); - if (hmrMode) { - // HMR: caller's URLs are by definition the authoritative copy. - // Overwrite unconditionally; any older cache entry is stale. - auto& slot = g_prefetchCache[urlCopy]; - slot = std::move(body); - scanSource = slot; - bodySize = slot.size(); - } else { - // Cold boot: insert-without-overwrite. Another path may have - // already landed this URL via opt-in speculative prefetch; - // honour whichever copy got there first. - auto inserted = g_prefetchCache.emplace(urlCopy, std::move(body)); - if (inserted.second) { - scanSource = inserted.first->second; - } else { - scanSource = inserted.first->second; - bodySize = inserted.first->second.size(); - } - } + g_prefetchCache[urlCopy] = std::move(body); } ctxCopy->fetchedCount.fetch_add(1, std::memory_order_relaxed); ctxCopy->bytes.fetch_add(bodySize, std::memory_order_relaxed); - if (ctxCopy->recursive) { - std::vector specs = - ScanStaticImportSpecifiers(scanSource, kPrefetchMaxImportsPerModule); - if (!specs.empty()) { - std::vector nextUrls; - nextUrls.reserve(specs.size()); - for (const std::string& spec : specs) { - std::string absUrl = ResolveImportSpecifierAgainstUrl(spec, urlCopy); - if (!absUrl.empty()) nextUrls.push_back(std::move(absUrl)); - } - if (!nextUrls.empty()) { - KickstartScheduleUrls(ctxCopy, std::move(nextUrls)); - } - } - } } ctxCopy->concurrency->Release(); ctxCopy->LeavePending(); @@ -2303,18 +1064,23 @@ void KickstartScheduleUrls(std::shared_ptr ctx, } } -bool KickstartRunSync(std::vector urls, int maxConcurrent, - double timeoutSeconds, bool recursive, const char* logLabel, - const std::string& diagSeed, size_t* outFetchedCount, - uint64_t* outElapsedMs) { - if (urls.empty()) return false; +} // anonymous namespace +bool KickstartHmrPrefetchUrlsSync(const std::vector& urls, + int maxConcurrent, + double timeoutSeconds, + size_t* outFetchedCount, + uint64_t* outElapsedMs) { + if (urls.empty()) return false; + // Drop empty / non-allowlisted URLs up front. We still want a + // truthy result even if some entries get filtered, because partial + // success is strictly better than the no-kickstart baseline. std::vector filtered; filtered.reserve(urls.size()); - for (auto& u : urls) { + for (const auto& u : urls) { if (u.empty()) continue; if (!IsRemoteUrlAllowed(u)) continue; - filtered.push_back(std::move(u)); + filtered.push_back(u); } if (filtered.empty()) return false; @@ -2323,15 +1089,22 @@ bool KickstartRunSync(std::vector urls, int maxConcurrent, const auto start = std::chrono::steady_clock::now(); + // Diagnostic seed — we record the first URL purely so the log line + // has a recognizable anchor when the user is correlating with their + // server-side `[hmr-ws][update] file=...` line. + const std::string diagSeed = filtered.front(); + const size_t requestedCount = filtered.size(); + auto ctx = std::make_shared(); ctx->concurrency = std::make_unique(maxConcurrent); - ctx->recursive = recursive; KickstartScheduleUrls(ctx, std::move(filtered)); - // Wait loop. Uses a slice-based timeout so the host runloop (e.g. the - // JS-thread pump) gets a chance to drain between slices. 50ms is short - // enough to feel responsive and long enough to avoid spinning. + // Wait loop. Uses a slice-based timeout so the host runloop (via the + // pluggable yield hook) gets a chance to drain between slices — this + // matters most during cold boot, before the dev client has called + // `__NS_DEV__.setDevBootComplete(true)`. 50ms is short enough to feel + // responsive and long enough to avoid spinning. const int sliceMs = 50; const auto deadline = start + std::chrono::milliseconds(static_cast(timeoutSeconds * 1000.0)); bool drained = false; @@ -2339,7 +1112,9 @@ bool KickstartRunSync(std::vector urls, int maxConcurrent, drained = ctx->WaitDrainSlice(sliceMs); if (drained) break; if (std::chrono::steady_clock::now() >= deadline) break; - InvokeHttpFetchYield(); + if (!IsDevSessionBootComplete()) { + InvokeHttpFetchYield(); + } } const auto end = std::chrono::steady_clock::now(); @@ -2352,90 +1127,113 @@ bool KickstartRunSync(std::vector urls, int maxConcurrent, if (outElapsedMs) *outElapsedMs = elapsedMs; if (IsScriptLoadingLogEnabled()) { - if (recursive) { - DEBUG_WRITE("[hmr-kickstart][%s] seed=%s fetched=%lu bytes=%lu ms=%llu status=%s concurrency=%d", - logLabel ? logLabel : "bfs", diagSeed.c_str(), - (unsigned long)fetched, (unsigned long)bytes, - (unsigned long long)elapsedMs, - drained ? "drained" : "timeout", maxConcurrent); - } else { - DEBUG_WRITE("[hmr-kickstart][%s] urls=%lu fetched=%lu bytes=%lu ms=%llu status=%s concurrency=%d", - logLabel ? logLabel : "list", (unsigned long)urls.size(), - (unsigned long)fetched, (unsigned long)bytes, - (unsigned long long)elapsedMs, - drained ? "drained" : "timeout", maxConcurrent); - } + DEBUG_WRITE("[hmr-kickstart][list] first=%s urls=%lu fetched=%lu bytes=%lu ms=%llu status=%s concurrency=%d", + diagSeed.c_str(), + (unsigned long)requestedCount, + (unsigned long)fetched, + (unsigned long)bytes, + (unsigned long long)elapsedMs, + drained ? "drained" : "timeout", + maxConcurrent); } - return drained; -} - -} // anonymous namespace -bool KickstartHmrPrefetchSync(const std::string& seedUrl, - int maxConcurrent, - double timeoutSeconds, - size_t* outFetchedCount, - uint64_t* outElapsedMs) { - if (seedUrl.empty()) return false; - // Opt-in gate (package.json "httpModulePrefetch", default false). Layered on - // top of the IsRemoteUrlAllowed network gate; when disabled, the speculative - // prefetch wave never runs. - if (!IsHttpModulePrefetchEnabled()) return false; - if (!IsRemoteUrlAllowed(seedUrl)) return false; - std::vector seeds{seedUrl}; - return KickstartRunSync(std::move(seeds), maxConcurrent, timeoutSeconds, - /*recursive=*/true, "bfs", seedUrl, - outFetchedCount, outElapsedMs); + return drained; } -bool KickstartHmrPrefetchUrlsSync(const std::vector& urls, - int maxConcurrent, - double timeoutSeconds, - size_t* outFetchedCount, - uint64_t* outElapsedMs) { - if (urls.empty()) return false; - // Opt-in gate (package.json "httpModulePrefetch", default false). Per-URL - // network access is still gated by IsRemoteUrlAllowed inside KickstartRunSync. - if (!IsHttpModulePrefetchEnabled()) return false; - std::string diagSeed; - for (const auto& u : urls) { - if (!u.empty()) { diagSeed = u; break; } - } - return KickstartRunSync(std::vector(urls), maxConcurrent, - timeoutSeconds, /*recursive=*/false, "list", - diagSeed, outFetchedCount, outElapsedMs); +void CleanupHMRGlobals() { + // Drop any kickstart-prewarmed module sources. These are plain + // std::string buffers (no v8::Global), but flushing them on teardown + // prevents stale source from leaking into a re-launched runtime in + // the same process. + ClearHttpModulePrefetchCache(); + ClearAllCacheBustMarks(); + // Reset the boot-complete flag so a re-launched runtime in the same + // process starts in "cold boot" mode again (yield pump armed). + g_devSessionBootComplete.store(false, std::memory_order_relaxed); } // ───────────────────────────────────────────────────────────── -// HMR + dev-session JS-callable globals. +// Dev-loader JS-callable globals // -// Installs the JS-callable globals the @nativescript/vite HMR client and -// deterministic dev-session bootstrap rely on. `Runtime::RunModule(const -// char*)` returns `void` on Android rather than `bool`, so failures are -// surfaced by -// catching `NativeScriptException`). +// The runtime's dev surface is deliberately small: it exposes +// *mechanism* only (resolution config, registry eviction, parallel +// prewarm, registry introspection, boot-complete signal). All HMR +// *policy* — boot orchestration, `import.meta.hot`, full reload, CSS +// apply, WebSocket protocol — lives in the JS dev client +// (`@nativescript/vite`). namespace { -// Helper used by both `__nsConfigureRuntime` and the `__nsConfigureDevRuntime` -// alias to apply the dev-session config payload, so both entry points behave -// identically. +// Sets the function name on the v8 Function for nicer stack traces and +// attaches it as a method of the `__NS_DEV__` namespace object. +void InstallDevFunction(v8::Isolate* isolate, v8::Local context, + v8::Local target, const char* name, + v8::FunctionCallback callback) { + v8::Local fnTpl = + v8::FunctionTemplate::New(isolate, callback); + v8::Local fn = fnTpl->GetFunction(context).ToLocalChecked(); + fn->SetName(ToV8String(isolate, name)); + target->CreateDataProperty(context, ToV8String(isolate, name), fn) + .Check(); +} + +// Parse an import-map value (a JSON string OR a JS object of shape +// `{ imports: { "": "", ... } }`) into flat (key → URL) entries using +// V8's own JSON/object model. Returns true if it found an `imports` object +// (even if empty); false if the value is unusable. Only flat string key→URL +// mappings are honored; non-string import values are skipped. +bool ReadImportMapEntries(v8::Isolate* isolate, + v8::Local context, + v8::Local importMapValue, + std::vector>* out) { + v8::Local mapVal = importMapValue; + if (mapVal->IsString()) { + v8::Local parsed; + if (!v8::JSON::Parse(context, mapVal.As()).ToLocal(&parsed)) { + return false; + } + mapVal = parsed; + } + if (!mapVal->IsObject()) return false; + v8::Local mapObj = mapVal.As(); + + v8::Local importsVal; + if (!mapObj->Get(context, ToV8String(isolate, "imports")).ToLocal(&importsVal) || + !importsVal->IsObject()) { + return false; + } + v8::Local imports = importsVal.As(); + v8::Local keys; + if (!imports->GetOwnPropertyNames(context).ToLocal(&keys)) return false; + + for (uint32_t i = 0; i < keys->Length(); ++i) { + v8::Local keyVal; + if (!keys->Get(context, i).ToLocal(&keyVal)) continue; + v8::Local valVal; + if (!imports->Get(context, keyVal).ToLocal(&valVal) || !valVal->IsString()) continue; + v8::String::Utf8Value keyUtf8(isolate, keyVal); + v8::String::Utf8Value valUtf8(isolate, valVal); + if (*keyUtf8 && *valUtf8) { + out->emplace_back(std::string(*keyUtf8), std::string(*valUtf8)); + } + } + return true; +} + +// `__NS_DEV__.configureRuntime(config)` — apply the dev client's resolver +// configuration: the bare-specifier import map and the volatile URL +// patterns. Bare-specifier resolution happens inside V8's synchronous +// `ResolveModuleCallback` — an embedder host callback JS cannot install +// or intercept — which is why this must be native. void ConfigureDevRuntimeCallback(const v8::FunctionCallbackInfo& info) { v8::Isolate* isolate = info.GetIsolate(); v8::HandleScope scope(isolate); v8::Local ctx = isolate->GetCurrentContext(); bool logScriptLoading = tns::IsScriptLoadingLogEnabled(); - // Defense in depth: never mutate the process-wide import map / volatile - // patterns in a release build. The install site (Runtime::PrepareV8Runtime) - // is already debug-gated, so this only fires if that gate is bypassed. - if (!tns::IsDebuggable()) { - return; - } - if (info.Length() < 1 || !info[0]->IsObject()) { if (logScriptLoading) { - DEBUG_WRITE("[__nsConfigureRuntime] expected config object argument"); + DEBUG_WRITE("[__NS_DEV__.configureRuntime] expected config object argument"); } return; } @@ -2452,7 +1250,7 @@ void ConfigureDevRuntimeCallback(const v8::FunctionCallbackInfo& info !importEntries.empty()) { SetImportMapEntries(importEntries); if (logScriptLoading) { - DEBUG_WRITE("[__nsConfigureRuntime] import map set (%zu entries)", importEntries.size()); + DEBUG_WRITE("[__NS_DEV__.configureRuntime] import map set (%zu entries)", importEntries.size()); } } } @@ -2473,196 +1271,10 @@ void ConfigureDevRuntimeCallback(const v8::FunctionCallbackInfo& info if (!patterns.empty()) { SetVolatilePatterns(patterns); if (logScriptLoading) { - DEBUG_WRITE("[__nsConfigureRuntime] %zu volatile patterns set", patterns.size()); - } - } - } -} - -// Helper: wrap Runtime::RunModule (which is `void` on Android) in a try/catch -// so we can report success/failure to the dev-session callbacks by treating -// any NativeScriptException as failure. -// -// `outErrorMessage` captures `ex.what()` from the inner NativeScriptException -// so the caller can pass the real cause through to the JS-side rejection -// instead of losing it behind a generic "failed to import" message. (The -// `[dev-session] RunModule failed for %s: %s` log is gated behind -// `logScriptLoading` so users who haven't opted in still see at least the -// wrapped reason via the rejected `__nsStartDevSession` promise.) -bool RunModuleSafe(Runtime* runtime, const std::string& url, - std::string* outErrorMessage = nullptr) { - try { - runtime->RunModule(url.c_str()); - return true; - } catch (NativeScriptException& ex) { - if (tns::IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dev-session] RunModule failed for %s: %s", - url.c_str(), ex.what()); - } - if (outErrorMessage) { - *outErrorMessage = ex.what() ? ex.what() : ""; - } - return false; - } catch (...) { - if (tns::IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dev-session] RunModule unknown exception for %s", url.c_str()); - } - if (outErrorMessage) { - *outErrorMessage = ""; - } - return false; - } -} - -void StartDevSessionCallback(const v8::FunctionCallbackInfo& info) { - v8::Isolate* isolate = info.GetIsolate(); - v8::HandleScope scope(isolate); - v8::Local ctx = isolate->GetCurrentContext(); - - // Defense in depth: dev sessions never start in a release build. The install - // site is already debug-gated; reject here too in case that gate is bypassed. - if (!tns::IsDebuggable()) { - info.GetReturnValue().Set(CreateRejectedPromise( - ctx, v8::Exception::Error(ToV8String( - isolate, - "[__nsStartDevSession] dev sessions are disabled in release builds")))); - return; - } - - if (info.Length() < 1 || !info[0]->IsObject()) { - info.GetReturnValue().Set(CreateRejectedPromise( - ctx, v8::Exception::TypeError( - ToV8String(isolate, "[__nsStartDevSession] expected config object")))); - return; - } - - v8::Local config = info[0].As(); - DevSessionState next; - std::string sessionError; - if (!ReadDevSessionConfig(isolate, ctx, config, &next, &sessionError)) { - info.GetReturnValue().Set(CreateRejectedPromise( - ctx, v8::Exception::TypeError(ToV8String(isolate, sessionError.c_str())))); - return; - } - - DevSessionState previous = GetActiveDevSessionSnapshot(); - bool sessionChanged = HasDevSessionChanged(previous, next); - bool logScriptLoading = tns::IsScriptLoadingLogEnabled(); - - if (sessionChanged && previous.active) { - std::vector staleUrls = CollectSessionModuleUrls(previous); - if (logScriptLoading) { - DEBUG_WRITE("[__nsStartDevSession] session changed old=%s new=%s invalidating=%lu", - previous.sessionId.c_str(), next.sessionId.c_str(), - (unsigned long)staleUrls.size()); - } - if (!staleUrls.empty()) { - InvalidateModules(staleUrls); - } - } - - if (!sessionChanged && previous.active && previous.started) { - if (logScriptLoading) { - DEBUG_WRITE("[__nsStartDevSession] session already active: %s", next.sessionId.c_str()); - } - info.GetReturnValue().Set(CreateResolvedPromise(isolate, ctx)); - return; - } - - // Optional native runtime-config delegation. Gated on a global flag the - // JS side may set to opt in. When disabled, the JS dev session is - // expected to call `__nsConfigureRuntime` itself. - bool nativeDelegation = false; - v8::Local delegationFlag; - if (ctx->Global() - ->Get(ctx, ToV8String(isolate, "__NS_EXPERIMENTAL_NATIVE_RUNTIME_CONFIG_URL__")) - .ToLocal(&delegationFlag) && - !delegationFlag->IsUndefined() && !delegationFlag->IsNull()) { - nativeDelegation = delegationFlag->BooleanValue(isolate); - } - if (!next.runtimeConfigUrl.empty() && nativeDelegation) { - if (logScriptLoading) { - DEBUG_WRITE("[__nsStartDevSession] runtimeConfigUrl fetch start session=%s url=%s", - next.sessionId.c_str(), next.runtimeConfigUrl.c_str()); - } - std::string runtimeConfigError; - if (!ApplyDevRuntimeConfigFromUrl(next.runtimeConfigUrl, &runtimeConfigError)) { - if (logScriptLoading) { - DEBUG_WRITE("[__nsStartDevSession] runtimeConfigUrl fetch failed session=%s url=%s", - next.sessionId.c_str(), next.runtimeConfigUrl.c_str()); + DEBUG_WRITE("[__NS_DEV__.configureRuntime] %zu volatile patterns set", patterns.size()); } - info.GetReturnValue().Set(CreateRejectedPromise( - ctx, v8::Exception::Error(ToV8String(isolate, runtimeConfigError.c_str())))); - return; - } - if (logScriptLoading) { - DEBUG_WRITE("[__nsStartDevSession] runtimeConfigUrl fetch complete session=%s url=%s", - next.sessionId.c_str(), next.runtimeConfigUrl.c_str()); - } - } else if (!next.runtimeConfigUrl.empty() && logScriptLoading) { - DEBUG_WRITE( - "[__nsStartDevSession] runtimeConfigUrl native delegation disabled; using JS-configured " - "runtime session=%s url=%s", - next.sessionId.c_str(), next.runtimeConfigUrl.c_str()); - } - - ApplyDevSessionGlobals(isolate, ctx, next); - StoreActiveDevSession(next); - - Runtime* runtime = Runtime::GetRuntime(isolate); - if (runtime == nullptr) { - if (logScriptLoading) { - DEBUG_WRITE("[__nsStartDevSession] runtime unavailable for session=%s", - next.sessionId.c_str()); - } - info.GetReturnValue().Set(CreateRejectedPromise( - ctx, v8::Exception::Error(ToV8String(isolate, "[__nsStartDevSession] runtime unavailable")))); - return; - } - - if (logScriptLoading) { - DEBUG_WRITE("[__nsStartDevSession] clientUrl import start session=%s url=%s", - next.sessionId.c_str(), next.clientUrl.c_str()); - } - { - std::string clientErr; - if (!RunModuleSafe(runtime, next.clientUrl, &clientErr)) { - std::string msg = std::string("[__nsStartDevSession] failed to import clientUrl: ") + - next.clientUrl + " — " + (clientErr.empty() ? "" : clientErr); - info.GetReturnValue().Set(CreateRejectedPromise( - ctx, v8::Exception::Error(ToV8String(isolate, msg.c_str())))); - return; - } - } - if (logScriptLoading) { - DEBUG_WRITE("[__nsStartDevSession] clientUrl import complete session=%s url=%s", - next.sessionId.c_str(), next.clientUrl.c_str()); - DEBUG_WRITE("[__nsStartDevSession] entryUrl import start session=%s url=%s", - next.sessionId.c_str(), next.entryUrl.c_str()); - } - { - std::string entryErr; - if (!RunModuleSafe(runtime, next.entryUrl, &entryErr)) { - std::string msg = std::string("[__nsStartDevSession] failed to import entryUrl: ") + - next.entryUrl + " — " + (entryErr.empty() ? "" : entryErr); - info.GetReturnValue().Set(CreateRejectedPromise( - ctx, v8::Exception::Error(ToV8String(isolate, msg.c_str())))); - return; } } - - next.started = true; - StoreActiveDevSession(next); - - if (logScriptLoading) { - DEBUG_WRITE("[__nsStartDevSession] entryUrl import complete session=%s url=%s", - next.sessionId.c_str(), next.entryUrl.c_str()); - DEBUG_WRITE("[__nsStartDevSession] session=%s platform=%s origin=%s client=%s entry=%s changed=%s", - next.sessionId.c_str(), next.platform.c_str(), next.origin.c_str(), - next.clientUrl.c_str(), next.entryUrl.c_str(), - sessionChanged ? "true" : "false"); - } - info.GetReturnValue().Set(CreateResolvedPromise(isolate, ctx)); } void InvalidateModulesCallback(const v8::FunctionCallbackInfo& info) { @@ -2672,7 +1284,7 @@ void InvalidateModulesCallback(const v8::FunctionCallbackInfo& info) if (info.Length() < 1 || !info[0]->IsArray()) { if (tns::IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[__nsInvalidateModules] expected array of URL strings"); + DEBUG_WRITE("[__NS_DEV__.invalidateModules] expected array of URL strings"); } return; } @@ -2685,6 +1297,10 @@ void InvalidateModulesCallback(const v8::FunctionCallbackInfo& info) v8::String::Utf8Value utf8(isolate, v); if (*utf8) urls.emplace_back(*utf8); } + // Observability: surface every URL the runtime is asked to drop so we + // can correlate "asked to evict X" against "actually had X loaded as + // Y" when canonicalization differs. Verbose-gated since per-event + // chatter is only useful while debugging an eviction mismatch. if (tns::IsScriptLoadingLogEnabled()) { DEBUG_WRITE("[ns-hmr][android-invalidate] called urls.count=%zu", urls.size()); size_t shown = 0; @@ -2700,6 +1316,16 @@ void InvalidateModulesCallback(const v8::FunctionCallbackInfo& info) InvalidateModules(urls); } +// `__NS_DEV__.kickstartPrefetch(urls, options?)` lets the HMR client tell +// the runtime "the next (re-)import will walk this module set — please +// pre-fill the loader cache with every listed body before V8 starts +// walking". The list is always server-computed (the dev server owns the +// module graph: eviction closures for HMR, entry-graph crawls for cold +// boot); the runtime performs no graph discovery of its own. A single +// string argument is accepted as a one-element list. +// +// Returns `{ ok, fetched, ms }` so JS can log the result. On failure +// callers should fall back to V8's normal synchronous walk. void KickstartHmrPrefetchCallback(const v8::FunctionCallbackInfo& info) { v8::Isolate* isolate = info.GetIsolate(); v8::HandleScope scope(isolate); @@ -2717,7 +1343,7 @@ void KickstartHmrPrefetchCallback(const v8::FunctionCallbackInfo& inf if (info.Length() < 1 || (!info[0]->IsString() && !info[0]->IsArray())) { if (tns::IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[__nsKickstartHmrPrefetch] expected (seedUrl: string, options?) or (urls: string[], options?)"); + DEBUG_WRITE("[__NS_DEV__.kickstartPrefetch] expected (urls: string[], options?) or (url: string, options?)"); } buildResult(false, 0, 0); return; @@ -2741,12 +1367,10 @@ void KickstartHmrPrefetchCallback(const v8::FunctionCallbackInfo& inf } } - size_t fetched = 0; - uint64_t elapsedMs = 0; + std::vector urls; if (info[0]->IsArray()) { v8::Local arr = info[0].As(); const uint32_t len = arr->Length(); - std::vector urls; urls.reserve(len); for (uint32_t i = 0; i < len; i++) { v8::Local elem; @@ -2758,149 +1382,60 @@ void KickstartHmrPrefetchCallback(const v8::FunctionCallbackInfo& inf if (s.empty()) continue; urls.push_back(std::move(s)); } - if (urls.empty()) { - buildResult(false, 0, 0); - return; + } else { + v8::String::Utf8Value u8(isolate, info[0]); + if (*u8) { + std::string s(*u8); + if (!s.empty()) urls.push_back(std::move(s)); } - bool ok = KickstartHmrPrefetchUrlsSync(urls, maxConcurrent, timeoutSeconds, - &fetched, &elapsedMs); - buildResult(ok, fetched, elapsedMs); - return; } - v8::String::Utf8Value seedUtf8(isolate, info[0]); - if (!*seedUtf8) { + if (urls.empty()) { buildResult(false, 0, 0); return; } - std::string seedUrl(*seedUtf8); - bool ok = KickstartHmrPrefetchSync(seedUrl, maxConcurrent, timeoutSeconds, - &fetched, &elapsedMs); + + size_t fetched = 0; + uint64_t elapsedMs = 0; + bool ok = KickstartHmrPrefetchUrlsSync(urls, maxConcurrent, timeoutSeconds, + &fetched, &elapsedMs); buildResult(ok, fetched, elapsedMs); } -void ReloadDevAppCallback(const v8::FunctionCallbackInfo& info) { +void GetLoadedModuleUrlsCallback(const v8::FunctionCallbackInfo& info) { v8::Isolate* isolate = info.GetIsolate(); v8::HandleScope scope(isolate); v8::Local ctx = isolate->GetCurrentContext(); - bool logScriptLoading = tns::IsScriptLoadingLogEnabled(); - - DevSessionState session = GetActiveDevSessionSnapshot(); - if (!session.active || session.entryUrl.empty()) { - if (logScriptLoading) { - DEBUG_WRITE("[__nsReloadDevApp] no active dev session"); - } - info.GetReturnValue().Set(CreateRejectedPromise( - ctx, v8::Exception::Error( - ToV8String(isolate, "[__nsReloadDevApp] no active dev session")))); - return; - } - std::vector sessionUrls = CollectSessionModuleUrls(session); - if (logScriptLoading) { - DEBUG_WRITE("[__nsReloadDevApp] invalidating session=%s urls=%lu", - session.sessionId.c_str(), (unsigned long)sessionUrls.size()); - } - if (!sessionUrls.empty()) { - InvalidateModules(sessionUrls); - } - SetDevSessionBootComplete(isolate, ctx, false); - Runtime* runtime = Runtime::GetRuntime(isolate); - if (runtime == nullptr) { - info.GetReturnValue().Set(CreateRejectedPromise( - ctx, v8::Exception::Error( - ToV8String(isolate, "[__nsReloadDevApp] runtime unavailable")))); - return; - } - if (logScriptLoading) { - DEBUG_WRITE("[__nsReloadDevApp] entryUrl import start session=%s url=%s", - session.sessionId.c_str(), session.entryUrl.c_str()); - } - if (!RunModuleSafe(runtime, session.entryUrl)) { - info.GetReturnValue().Set(CreateRejectedPromise( - ctx, v8::Exception::Error( - ToV8String(isolate, "[__nsReloadDevApp] failed to import entryUrl")))); - return; - } - if (logScriptLoading) { - DEBUG_WRITE("[__nsReloadDevApp] entryUrl import complete session=%s url=%s", - session.sessionId.c_str(), session.entryUrl.c_str()); - DEBUG_WRITE("[__nsReloadDevApp] session=%s reload complete (invalidated=%lu)", - session.sessionId.c_str(), (unsigned long)sessionUrls.size()); + std::vector urls = GetLoadedModuleUrls(); + v8::Local result = v8::Array::New(isolate, static_cast(urls.size())); + for (uint32_t i = 0; i < urls.size(); i++) { + (void)result->Set(ctx, i, ToV8String(isolate, urls[i].c_str())); } - info.GetReturnValue().Set(CreateResolvedPromise(isolate, ctx)); + info.GetReturnValue().Set(result); } -void ApplyStyleUpdateCallback(const v8::FunctionCallbackInfo& info) { +// `__NS_DEV__.setDevBootComplete(value?: boolean)` — the JS dev client calls +// this (with `true`, or no argument) once the real app root view has +// committed. It flips both the JS-visible `__NS_HMR_BOOT_COMPLETE__` +// global and the native atomic that disarms the cold-boot yield cadence +// in the kickstart pump-wait. The client may also pass `false` before +// a full JS-realm reload to re-arm the boot-time behaviors. +void SetDevBootCompleteCallback(const v8::FunctionCallbackInfo& info) { v8::Isolate* isolate = info.GetIsolate(); v8::HandleScope scope(isolate); v8::Local ctx = isolate->GetCurrentContext(); - const bool logEnabled = tns::IsScriptLoadingLogEnabled(); - if (info.Length() < 1 || !info[0]->IsObject()) { - if (logEnabled) DEBUG_WRITE("[__nsApplyStyleUpdate] expected payload object"); - return; - } - v8::Local payload = info[0].As(); - std::string cssText; - std::string url; - GetOptionalStringProperty(isolate, ctx, payload, "cssText", &cssText); - GetOptionalStringProperty(isolate, ctx, payload, "url", &url); - if (cssText.empty()) { - if (logEnabled) DEBUG_WRITE("[__nsApplyStyleUpdate] missing cssText payload"); - return; + bool value = true; + if (info.Length() >= 1 && !info[0]->IsUndefined() && !info[0]->IsNull()) { + value = info[0]->BooleanValue(isolate); } - v8::Local applicationValue; - if (!ctx->Global()->Get(ctx, ToV8String(isolate, "Application")).ToLocal(&applicationValue) || - !applicationValue->IsObject()) { - if (logEnabled) DEBUG_WRITE("[__nsApplyStyleUpdate] Application is unavailable for %s", url.c_str()); - return; - } - v8::Local applicationObject = applicationValue.As(); - v8::Local addCssValue; - if (!applicationObject->Get(ctx, ToV8String(isolate, "addCss")).ToLocal(&addCssValue) || - !addCssValue->IsFunction()) { - if (logEnabled) DEBUG_WRITE("[__nsApplyStyleUpdate] Application.addCss is unavailable for %s", url.c_str()); - return; - } - v8::TryCatch tc(isolate); - v8::Local args[] = {ToV8String(isolate, cssText.c_str())}; - v8::Local ignored; - bool addCssCalled = - addCssValue.As()->Call(ctx, applicationObject, 1, args).ToLocal(&ignored); - if (addCssCalled && !tc.HasCaught()) { - v8::Local getRootViewValue; - if (applicationObject->Get(ctx, ToV8String(isolate, "getRootView")).ToLocal(&getRootViewValue) && - getRootViewValue->IsFunction()) { - v8::Local rootViewValue; - if (getRootViewValue.As() - ->Call(ctx, applicationObject, 0, nullptr) - .ToLocal(&rootViewValue) && - rootViewValue->IsObject()) { - v8::Local rootViewObject = rootViewValue.As(); - v8::Local cssStateChangeValue; - if (rootViewObject->Get(ctx, ToV8String(isolate, "_onCssStateChange")) - .ToLocal(&cssStateChangeValue) && - cssStateChangeValue->IsFunction()) { - (void)cssStateChangeValue.As() - ->Call(ctx, rootViewObject, 0, nullptr) - .ToLocal(&ignored); - } - } - } - } - if (tc.HasCaught() && logEnabled) { - DEBUG_WRITE("[__nsApplyStyleUpdate] failed for %s", url.c_str()); - } - if (logEnabled) { - DEBUG_WRITE("[__nsApplyStyleUpdate] applied %s", url.c_str()); - } + tns::SetDevBootComplete(isolate, ctx, value); } -// Debug-only diagnostic: expose CanonicalizeHttpUrlKey to JS so the test harness -// can pin its identity behavior. Not part of the @nativescript/vite client API. -// The whole installer is gated on isDebuggable at the call site, so this never -// ships in release. +// Debug-only diagnostic: expose CanonicalizeHttpUrlKey to JS so the test +// harness can pin its identity behavior. Not part of the @nativescript/vite +// client API; release builds omit it. void CanonicalizeHttpUrlKeyCallback(const v8::FunctionCallbackInfo& info) { v8::Isolate* isolate = info.GetIsolate(); v8::HandleScope scope(isolate); @@ -2913,103 +1448,48 @@ void CanonicalizeHttpUrlKeyCallback(const v8::FunctionCallbackInfo& i info.GetReturnValue().Set(ToV8String(isolate, key.c_str())); } -void GetLoadedModuleUrlsCallback(const v8::FunctionCallbackInfo& info) { - v8::Isolate* isolate = info.GetIsolate(); - v8::HandleScope scope(isolate); - v8::Local ctx = isolate->GetCurrentContext(); - std::vector urls = GetLoadedModuleUrls(); - v8::Local result = v8::Array::New(isolate, static_cast(urls.size())); - for (uint32_t i = 0; i < urls.size(); i++) { - (void)result->Set(ctx, i, ToV8String(isolate, urls[i].c_str())); - } - info.GetReturnValue().Set(result); -} - -void InstallGlobalFunction(v8::Isolate* isolate, v8::Local context, - const char* name, v8::FunctionCallback callback) { - v8::Local fnTpl = v8::FunctionTemplate::New(isolate, callback); - v8::Local fn = fnTpl->GetFunction(context).ToLocalChecked(); - fn->SetName(ToV8String(isolate, name)); - context->Global()->Set(context, ToV8String(isolate, name), fn).FromMaybe(false); - MirrorFunctionOnGlobalThis(isolate, context, name); -} - } // anonymous namespace -void InitializeHmrDevGlobals(v8::Isolate* isolate, v8::Local context) { - // Install the per-module HMR helpers and the dev-session global surface. - // The main-thread AND debug-mode gating happens at the SINGLE call site in - // `Runtime::PrepareV8Runtime` (`if (m_isMainThread && isDebuggable)`), so a - // release build never reaches this function. The session-mutating callbacks - // below additionally fail safe via `tns::IsDebuggable()` as defense in depth - // in case a future caller forgets the call-site gate. - try { - InitializeHotEventDispatcher(isolate, context); - InitializeHotDisposeRunner(isolate, context); - InitializeHotPruneRunner(isolate, context); - InitializeHotDeclinedHelper(isolate, context); - } catch (...) { - // Don't crash if HMR setup fails — the rest of init must still run. +void InitializeHmrDevGlobals(v8::Isolate* isolate, v8::Local context, + bool isWorker) { + // The dev host API lives here: `__NS_DEV__`. + // + // Installed in EVERY build, release included. That is deliberate: the + // security boundary sits at the network layer, not the namespace — + // every HTTP fetch the runtime can make passes through + // `IsRemoteUrlAllowed()` (DevFlags.cpp), which in release builds denies + // everything unless the app config explicitly opts in via + // `security.allowRemoteModules`. Remote module loading is a supported + // release feature behind that opt-in, and an opted-in app needs + // `configureRuntime` / `invalidateModules` / `kickstartPrefetch` to + // operate it. For default-config release apps the members are inert. + v8::Local dev = v8::Object::New(isolate); + + InstallDevFunction(isolate, context, dev, "configureRuntime", ConfigureDevRuntimeCallback); + InstallDevFunction(isolate, context, dev, "invalidateModules", InvalidateModulesCallback); + InstallDevFunction(isolate, context, dev, "kickstartPrefetch", KickstartHmrPrefetchCallback); + InstallDevFunction(isolate, context, dev, "getLoadedModuleUrls", GetLoadedModuleUrlsCallback); + InstallDevFunction(isolate, context, dev, "setDevBootComplete", SetDevBootCompleteCallback); + + // Main-isolate only: terminating workers from inside a worker would let + // a stuck worker take down its peers (see CallbackHandlers.h). + if (!isWorker) { + InstallDevFunction(isolate, context, dev, "terminateAllWorkers", + CallbackHandlers::TerminateAllWorkersCallback); + } + + if (IsDebuggable()) { + // Debug-only diagnostic: expose the HTTP canonical-key function to JS so + // the test harness can pin its identity behavior across cache-busters + // and dev-endpoint query normalization. + InstallDevFunction(isolate, context, dev, "canonicalizeHttpUrlKey", + CanonicalizeHttpUrlKeyCallback); } - // Install the dev-session bootstrap surface. - InstallGlobalFunction(isolate, context, "__nsConfigureDevRuntime", ConfigureDevRuntimeCallback); - InstallGlobalFunction(isolate, context, "__nsConfigureRuntime", ConfigureDevRuntimeCallback); - (void)context->Global() - ->CreateDataProperty(context, ToV8String(isolate, "__nsSupportsRuntimeConfigUrl"), - v8::Boolean::New(isolate, true)) + context->Global() + ->Set(context, ToV8String(isolate, "__NS_DEV__"), dev) .FromMaybe(false); - - InstallGlobalFunction(isolate, context, "__nsStartDevSession", StartDevSessionCallback); - InstallGlobalFunction(isolate, context, "__nsInvalidateModules", InvalidateModulesCallback); - InstallGlobalFunction(isolate, context, "__nsKickstartHmrPrefetch", KickstartHmrPrefetchCallback); - InstallGlobalFunction(isolate, context, "__nsReloadDevApp", ReloadDevAppCallback); - InstallGlobalFunction(isolate, context, "__nsApplyStyleUpdate", ApplyStyleUpdateCallback); - InstallGlobalFunction(isolate, context, "__nsGetLoadedModuleUrls", GetLoadedModuleUrlsCallback); - InstallGlobalFunction(isolate, context, "__nsCanonicalizeHttpUrlKey", CanonicalizeHttpUrlKeyCallback); -} - -void CleanupHMRGlobals() { - // Reset all v8::Global handles BEFORE the isolate is disposed. - // These static maps survive past isolate teardown and their destructors - // (__cxa_finalize_ranges) would call v8::Global::Reset() on an already- - // destroyed isolate, causing a crash in v8::internal::GlobalHandles::Destroy(). - for (auto& kv : g_hotData) { kv.second.Reset(); } - g_hotData.clear(); - - for (auto& kv : g_hotAccept) { - for (auto& fn : kv.second) { fn.Reset(); } - } - g_hotAccept.clear(); - - for (auto& kv : g_hotDispose) { - for (auto& fn : kv.second) { fn.Reset(); } - } - g_hotDispose.clear(); - - for (auto& kv : g_hotPrune) { - for (auto& fn : kv.second) { fn.Reset(); } - } - g_hotPrune.clear(); - - for (auto& kv : g_hotEventListeners) { - for (auto& fn : kv.second) { fn.Reset(); } - } - g_hotEventListeners.clear(); - - { - // `g_hotDeclined` holds plain strings — no v8::Global handles — but - // we still clear it under its own mutex on teardown so a re-launched - // runtime in the same process starts with a clean slate. - std::lock_guard lock(g_hotDeclinedMutex); - g_hotDeclined.clear(); - } - - // Drop any speculatively-prefetched module sources. These are plain - // std::string buffers (no v8::Global), but flushing them on teardown - // prevents stale source from leaking into a re-launched runtime in - // the same process. - ClearHttpModulePrefetchCache(); + MirrorGlobalOnGlobalThis(isolate, context, "__NS_DEV__"); } } // namespace tns diff --git a/test-app/runtime/src/main/cpp/HMRSupport.h b/test-app/runtime/src/main/cpp/HMRSupport.h index 3ea83f864..d769e75e6 100644 --- a/test-app/runtime/src/main/cpp/HMRSupport.h +++ b/test-app/runtime/src/main/cpp/HMRSupport.h @@ -13,112 +13,40 @@ class Object; class Function; class Context; class Value; -class Promise; } namespace tns { -// HMRSupport: Isolated helpers for minimal HMR (import.meta.hot) support. -// -// This module contains: -// - Per-module hot data store -// - Registration for accept/disable callbacks -// - Active dev-session state and helpers -// - Initializer to attach import.meta.hot to a module's import.meta -// -// Note: Triggering/dispatch is handled by the HMR system elsewhere. - -// Retrieve or create the per-module hot data object. -v8::Local GetOrCreateHotData(v8::Isolate* isolate, const std::string& key); - -// Register accept and dispose callbacks for a module key. -void RegisterHotAccept(v8::Isolate* isolate, const std::string& key, v8::Local cb); -void RegisterHotDispose(v8::Isolate* isolate, const std::string& key, v8::Local cb); - -// Register prune callbacks for a module key. Per Vite spec these fire when the -// module is removed from the dependency graph (NOT on every update — that is -// dispose). The registry is plumbed end-to-end; a per-module HMR client drains -// it via `__nsRunHmrPrune`. -void RegisterHotPrune(v8::Isolate* isolate, const std::string& key, v8::Local cb); - -// Optional: expose read helpers (may be useful for debugging/integration) -std::vector> GetHotAcceptCallbacks(v8::Isolate* isolate, const std::string& key); -std::vector> GetHotDisposeCallbacks(v8::Isolate* isolate, const std::string& key); -std::vector> GetHotPruneCallbacks(v8::Isolate* isolate, const std::string& key); - -// `import.meta.hot` implementation — Vite-spec compliant API surface. -// -// Per-module API exposed on every imported module: -// - `hot.data` — per-module persistent object across HMR updates -// - `hot.accept(deps?, cb?)` — register a self-accepting handler (deps arg accepted but currently ignored) -// - `hot.dispose(cb)` — register a cleanup callback fired when this module is replaced -// - `hot.prune(cb)` — register a callback fired when this module is removed from the dep graph -// - `hot.decline()` — opt this module out of HMR (next update touching it triggers full reload) -// - `hot.invalidate(msg?)` — request a full app reload from this module (delegates to `__nsReloadDevApp`) -// - `hot.on(event, cb)` — listen to HMR events (Vite standard `vite:beforeUpdate` / `vite:afterUpdate` / -// `vite:beforeFullReload` / `vite:beforePrune` / `vite:invalidate` / `vite:error`, -// plus custom events the HMR client dispatches via `__NS_DISPATCH_HOT_EVENT__`) -// - `hot.off(event, cb)` — unregister a listener previously added with `hot.on` -// - `hot.send(event, data)` — send a custom message to the dev server; delegated to a JS-installed -// `globalThis.__nsHmrSendToServer(event, data)` so the WebSocket-owning JS layer -// keeps sole responsibility for the transport (runtime stays transport-agnostic) -// -// `modulePath` is used to derive the per-module canonical key for `hot.data` and callback registries. -void InitializeImportMetaHot(v8::Isolate* isolate, - v8::Local context, - v8::Local importMeta, - const std::string& modulePath); - -// ───────────────────────────────────────────────────────────── -// Dev session helpers - -struct DevSessionState { - bool active = false; - bool started = false; - std::string sessionId; - std::string origin; - std::string entryUrl; - std::string clientUrl; - std::string wsUrl; - std::string platform; - std::string runtimeConfigUrl; - bool fullReload = false; - bool cssHmr = false; -}; - -// Read and validate the JS dev-session config object. -bool ReadDevSessionConfig(v8::Isolate* isolate, - v8::Local context, - v8::Local config, - DevSessionState* out, - std::string* errorMessage); - -// Active dev-session storage. -void ResetActiveDevSession(); -DevSessionState GetActiveDevSessionSnapshot(); -void StoreActiveDevSession(const DevSessionState& session); -bool HasDevSessionChanged(const DevSessionState& previous, - const DevSessionState& next); -std::vector CollectSessionModuleUrls(const DevSessionState& session); -bool ApplyDevRuntimeConfigFromUrl(const std::string& url, - std::string* errorMessage); - -// Runtime global helpers for the deterministic dev session boot path. -void ApplyDevSessionGlobals(v8::Isolate* isolate, - v8::Local context, - const DevSessionState& session); -void SetDevSessionBootComplete(v8::Isolate* isolate, - v8::Local context, - bool value); +// HMRSupport: the native half of the NativeScript dev-loader contract. +// +// The runtime deliberately exposes *mechanism* only: +// - the synchronous HTTP text fetch backing the HTTP ESM loader +// (V8's ResolveModuleCallback is synchronous, so the fetch must be +// native), +// - a body prewarm cache + list-mode kickstart so a server-computed +// module closure can be fetched in one parallel wave before V8's +// serial synchronous walk, +// - eviction plumbing (prefetch-cache evict + an eviction-driven +// fetch nonce that defeats any HTTP cache layer between the +// runtime and the dev server), +// - the dev-boot-complete signal that disarms cold-boot-only +// behaviors (host yield pump, kickstart pump-wait). +// +// Everything else — boot orchestration, `import.meta.hot`, hot-callback +// registries, full reload, CSS apply, WebSocket protocol — is HMR +// *policy* and lives in the JS dev client (`@nativescript/vite`). // ───────────────────────────────────────────────────────────── // HTTP loader helpers (used by dev/HMR and general-purpose HTTP module loading) // // Normalize an HTTP(S) URL into a stable module registry/cache key. // - Always strips URL fragments. -// - For NativeScript dev endpoints, normalizes known cache busters (e.g. t/v/import) -// and normalizes some versioned bridge paths. -// - For non-dev/public URLs, preserves the full query string as part of the cache key. +// - For NativeScript dev endpoints, drops known cache busters (t/v/import) +// and sorts remaining query params for stability. +// - For non-dev/public URLs, preserves the full query string as part of the +// cache key. +// Module identity IS the (canonical) URL — the dev server serves every +// module under exactly one URL and never varies it for freshness. std::string CanonicalizeHttpUrlKey(const std::string& url); // Resolve a relative/root-absolute import specifier against a parent URL @@ -135,11 +63,10 @@ std::string ResolveImportSpecifierAgainstUrl(const std::string& specifier, // - contentType: Content-Type header if present // - status: HTTP status code // -// On a fast path, returns from the in-memory speculative-prefetch cache -// without touching the network. On the slow path, performs a synchronous -// fetch and additionally schedules background prefetches for the body's -// static imports so subsequent HttpFetchText calls hit the cache. See -// the prefetcher block in HMRSupport.cpp for full design notes. +// On a fast path, returns from the in-memory kickstart-prewarm cache +// without touching the network (destructive one-shot read). On the slow +// path, performs a synchronous JNI fetch with a bounded retry on +// transient socket-pool failures. bool HttpFetchText(const std::string& url, std::string& out, std::string& contentType, int& status); // Return the most recent low-level fetch error reason for the calling @@ -158,226 +85,105 @@ bool HttpFetchText(const std::string& url, std::string& out, std::string& conten // can never leak into a later, successful fetch. std::string TakeLastHttpFetchErrorReason(); -// Drop all entries in the speculative-prefetch cache. Safe to call from -// any thread. Used by Runtime teardown and by HMR cache-poison scenarios -// where the dev server has indicated a graph version bump. +// Drop all entries in the prewarm cache. Safe to call from any thread. +// Used by Runtime teardown and by HMR cache-poison scenarios where the +// dev server has indicated a graph version bump. void ClearHttpModulePrefetchCache(); // Register a "yield" callback that `HttpFetchText` should invoke around its // synchronous network turn so the caller can pump its own runloop (e.g. the // JS-thread runloop so a placeholder UI can repaint during cold-boot). // -// Default: a no-op (`NoopHttpFetchYield`). Android's main NativeScript -// isolate runs JS on the UI thread, so there is no separate JS-thread -// runloop to pump here; a host that drives its own loop can install a real -// pump (e.g. one calling ALooper_pollOnce(0)) via this hook. +// Default: a no-op. Android's main NativeScript isolate runs JS on the UI +// thread, so there is no separate JS-thread runloop to pump here; a host +// that drives its own loop can install a real pump (e.g. one calling +// ALooper_pollOnce(0)) via this hook. // // Pass `nullptr` to disable any yielding (used by hosts that drive their own // run loop or by tests that want bit-for-bit deterministic fetch timing). // Safe to call from any thread; reads use acquire/release ordering. void RegisterHttpFetchYield(void (*callback)()); -// Drop a specific URL set from the speculative-prefetch cache. Safe -// to call from any thread; missing keys are silently ignored. Used by -// `InvalidateModules` so that an HMR eviction also purges any stale -// HTTP body the previous prefetch wave (or kickstart) left behind. -// Without this, the kickstart's "skip if URL already cached" -// early-out, plus `HttpFetchText`'s destructive-read fast path, would -// happily serve V8 a stale body from the prior save — visible to the -// user as a 1-cycle lag between save and visual update. +// Drop a specific URL set from the prewarm cache. Safe to call from any +// thread; missing keys are silently ignored. Used by `InvalidateModules` +// so that an HMR eviction also purges any stale HTTP body a previous +// kickstart wave left behind. Without this, the kickstart's cache plus +// `HttpFetchText`'s destructive-read fast path would happily serve V8 a +// stale body from the prior save — visible to the user as a 1-cycle lag +// between save and visual update. void EvictHttpModulePrefetchCacheUrls(const std::vector& urls); -// Kickstart an HMR-driven module prefetch -// rooted at `seedUrl`. Walks the static-import graph in parallel (up to -// `maxConcurrent` simultaneous HTTP fetches), storing every reachable -// module body in the speculative-prefetch cache. Blocks the calling -// thread until the BFS has fully drained or `timeoutSeconds` elapses. -// -// Designed to be invoked from JS (via `__nsKickstartHmrPrefetch`) -// immediately before the Angular HMR client re-imports the entry — -// by the time V8 walks the dep tree, every reachable body is already -// in `g_prefetchCache` and the walk runs at memory speed instead of -// network speed (turning a ~3s 200-fetch refresh into ~250ms). -// -// Returns `true` when the BFS drained cleanly. On timeout or seed -// fetch failure returns `false`; callers should treat that as "no -// kickstart speedup this round" and fall back to V8's normal -// synchronous walk, which always succeeds independently. +// Mark a URL set (canonicalized internally) so that the NEXT network +// fetch of each URL carries a unique `__ns_dev_nonce` query parameter, +// guaranteeing no HTTP cache layer between the runtime and the dev +// server (OS URL caches, proxies, a host-installed HttpResponseCache) +// can satisfy the request. Called by `InvalidateModules` for the +// eviction set; marks are consumed when a fresh body arrives. +// The nonce is transport-only and never affects module identity. +void MarkUrlsForCacheBust(const std::vector& urls); + +// List-mode kickstart prewarm. Fetches ONLY the explicit URL list it +// was given (no body scanning, no graph recursion — the dev server owns +// the module graph and supplies closures: `evictPaths` for HMR, an +// entry-graph crawl for cold boot). Fetches run in parallel (up to +// `maxConcurrent`), each body landing in the prewarm cache that +// `HttpFetchText` reads. Blocks the calling thread until the wave +// drains or `timeoutSeconds` elapses. +// +// By feeding the precomputed list we turn N sequential +// `LoadHttpModuleForUrl` calls (the importer chain during V8's +// ResolveModuleCallback walk) into a single parallel wave that +// completes before V8 starts walking. +// +// Cleared/blocked URLs are filtered up front; partial success is +// reported as success (the V8 walk falls back to per-module +// HttpFetchText for anything we couldn't pre-fill). // // `outFetchedCount` (optional) receives the number of distinct URLs // fetched. `outElapsedMs` (optional) receives wall-clock time. -bool KickstartHmrPrefetchSync(const std::string& seedUrl, - int maxConcurrent, - double timeoutSeconds, - size_t* outFetchedCount, - uint64_t* outElapsedMs); - -// Multi-URL kickstart for HMR cycles. Unlike the legacy seed-rooted -// variant above, this one fetches ONLY the explicit URL list it was -// given (no body scanning, no BFS recursion). -// -// This is the right shape for HMR: the dev server's -// `collectAngularEvictionUrls` already computed the inverse-dep -// closure of the changed file; re-discovering it via in-process -// scanning would just duplicate that work and re-fetch modules V8 -// has already compiled. By feeding the precomputed list directly we -// turn N sequential `LoadHttpModuleForUrl` calls (the importer chain -// during V8's ResolveModuleCallback walk) into a single parallel -// wave that completes before V8 starts walking. -// -// Same semantics as `KickstartHmrPrefetchSync` for everything else: -// blocks the calling thread until the wave drains or `timeoutSeconds` -// elapses; cleared/blocked URLs are filtered up front; partial -// success is reported as success (the V8 walk falls back to -// per-module HttpFetchText for anything we couldn't pre-fill). bool KickstartHmrPrefetchUrlsSync(const std::vector& urls, int maxConcurrent, double timeoutSeconds, size_t* outFetchedCount, uint64_t* outElapsedMs); -// Clear all HMR-related v8::Global handles (g_hotData, g_hotAccept, g_hotDispose). -// MUST be called inside Runtime::~Runtime() before isolate disposal to prevent -// crashes during static destructor cleanup (__cxa_finalize_ranges). +// Flip the dev-boot-complete signal: sets the JS-visible +// `__NS_HMR_BOOT_COMPLETE__` global and the native atomic that gates the +// cold-boot-only behaviors (kickstart pump-wait yield cadence). Exposed +// to JS as `__NS_DEV__.setDevBootComplete(value?: boolean)`. +void SetDevBootComplete(v8::Isolate* isolate, v8::Local context, + bool value); + +// Clear process-wide dev-loader state (prewarm cache, cache-bust marks, +// boot-complete flag). MUST be called during Runtime teardown before +// isolate disposal — and only for the MAIN isolate (worker teardown must +// not wipe shared state the main isolate still uses). void CleanupHMRGlobals(); -// ───────────────────────────────────────────────────────────── -// Custom HMR event support - -// Register a custom event listener (called by import.meta.hot.on()) -void RegisterHotEventListener(v8::Isolate* isolate, const std::string& event, v8::Local cb); - -// Unregister a listener previously added with `RegisterHotEventListener`. The -// callback is matched by V8 strict equality (same `Function` reference). If -// `cb` matches multiple registered listeners (the same closure was registered -// twice), every match is removed — mirrors `EventTarget.removeEventListener` -// semantics for repeated registrations. -void RemoveHotEventListener(v8::Isolate* isolate, const std::string& event, v8::Local cb); - -// Get all listeners for a custom event -std::vector> GetHotEventListeners(v8::Isolate* isolate, const std::string& event); -// Dispatch a custom event to all registered listeners -// This should be called when the HMR WebSocket receives framework-specific events -void DispatchHotEvent(v8::Isolate* isolate, v8::Local context, const std::string& event, v8::Local data); - -// Initialize the global event dispatcher function (__NS_DISPATCH_HOT_EVENT__) -// This exposes a JavaScript-callable function that the HMR client can use to dispatch events -void InitializeHotEventDispatcher(v8::Isolate* isolate, v8::Local context); - -// Drain and execute `import.meta.hot.dispose(cb)` callbacks for the given module -// keys. If `keys` is empty, drains every registered callback across every module -// (the right behaviour for whole-app HMR reboots like Angular's -// `__reboot_ng_modules__`, where the entire JS realm's side effects are being -// thrown away). Each callback is invoked with that module's `hot.data` object so -// users can persist state across the reload (matches Vite spec). -// -// Callbacks are removed from the registry after execution so a second drain in -// the same cycle is a clean no-op. Per-callback failures are logged (when -// script-loading logs are enabled) but never propagate — one bad disposer must -// not break the HMR cycle for everyone else. -// -// Returns the number of callbacks successfully executed. -int RunHotDisposeCallbacks(v8::Isolate* isolate, v8::Local context, - const std::vector& keys); - -// Initialize the global `__nsRunHmrDispose([keys?])` function so the HMR client -// (e.g. @nativescript/vite's Angular HMR client) can drain dispose callbacks -// from JS. Mirrors the `InitializeHotEventDispatcher` pattern. Should be called -// once per main isolate during runtime init, gated on dev mode. -// -// JS signature: `__nsRunHmrDispose(keys?: string[]) => number` -// - `keys` omitted / null / undefined / empty array → drain everything. -// - `keys` non-empty → drain only the listed module keys. -// - Returns: count of callbacks executed. -void InitializeHotDisposeRunner(v8::Isolate* isolate, v8::Local context); - -// Drain `import.meta.hot.prune(cb)` callbacks for the given module keys (or -// every registered module if `keys` is empty). Same snapshot/swap semantics as -// `RunHotDisposeCallbacks` — callbacks fire exactly once per drain, the -// registry is cleared atomically per key, and per-callback failures are logged -// but never propagate. -// -// Returns the number of callbacks successfully executed. -int RunHotPruneCallbacks(v8::Isolate* isolate, v8::Local context, - const std::vector& keys); - -// Initialize the global `__nsRunHmrPrune([keys?])` function. Symmetric with -// `__nsRunHmrDispose` but for `prune` callbacks. -// -// JS signature: `__nsRunHmrPrune(keys?: string[]) => number` -void InitializeHotPruneRunner(v8::Isolate* isolate, v8::Local context); - -// `decline()` support. When user code calls `import.meta.hot.decline()`, the -// module's canonical key is added to a process-wide declined set. The HMR -// client checks `IsAnyModuleDeclined(updatedKeys)` before applying an update — -// if any updated key is declined, the update is converted into a full reload -// (matches Vite spec: "If the module triggers HMR, full reload occurs"). -void MarkHotDeclined(const std::string& key); - -// Returns true if the given key is in the declined set. Used by the -// `__nsHasDeclinedModule` JS helper below. -bool IsHotDeclined(const std::string& key); - -// Returns true if ANY of the supplied keys are in the declined set, OR if -// the declined set is non-empty AND `keys` is empty (caller is asking -// "is anything declined at all?"). The runtime canonicalizes its registry -// keys via `canonicalHotKey` (strips fragments, normalizes script extensions, -// rewrites NS HMR virtual prefixes); the HMR client should pass canonical -// URLs straight from `evictPaths` for accurate matching. -bool IsAnyModuleDeclined(const std::vector& keys); - -// Initialize the global `__nsHasDeclinedModule([keys?])` function. Returns -// `true` if any of the listed keys is declined (or if the declined set is -// non-empty AND no keys were passed). The Angular HMR client calls this with -// `evictPaths` before reboot; on `true` it falls back to `__nsReloadDevApp()`. -// -// JS signature: `__nsHasDeclinedModule(keys?: string[]) => boolean` -void InitializeHotDeclinedHelper(v8::Isolate* isolate, v8::Local context); +// Mirror a globally-installed value onto `globalThis.` so +// `globalThis.` lookups resolve when the runtime installs the +// canonical value on the realm's global object. +void MirrorGlobalOnGlobalThis(v8::Isolate* isolate, v8::Local context, + const char* name); // ───────────────────────────────────────────────────────────── -// Small v8 utility helpers (shared between Runtime.cpp and HMRSupport.cpp). -// Declared here once so both translation units share a single definition. - -// Read an optional string property from `object` into `*out`. Returns false -// if the property is missing, null, undefined, or non-convertible. -bool GetOptionalStringProperty(v8::Isolate* isolate, v8::Local context, - v8::Local object, const char* key, - std::string* out); - -// Construct an already-resolved Promise. -v8::Local CreateResolvedPromise(v8::Isolate* isolate, - v8::Local context); - -// Construct an already-rejected Promise with the given reason. -v8::Local CreateRejectedPromise(v8::Local context, - v8::Local reason); - -// Mirror a globally-installed function onto `globalThis.` so legacy -// `globalThis.__nsXxx(...)` callers keep working when the runtime installs -// the canonical function on the realm's global object via FunctionTemplate. -void MirrorFunctionOnGlobalThis(v8::Isolate* isolate, v8::Local context, - const char* name); - -// ───────────────────────────────────────────────────────────── -// HMR + dev-session global installer -// -// Installs every JS-callable global the @nativescript/vite HMR client and the -// dev-session bootstrap depend on. Idempotent per realm; safe to call from any -// place that has a fresh context + isolate scope. -// -// JS globals installed (all on the realm's global object AND mirrored on -// globalThis): -// - __nsConfigureDevRuntime / __nsConfigureRuntime (import map + volatile patterns) -// - __nsSupportsRuntimeConfigUrl (data property, true) -// - __nsStartDevSession (async session bootstrap) -// - __nsInvalidateModules (registry eviction) -// - __nsKickstartHmrPrefetch (parallel HTTP prewarm) -// - __nsReloadDevApp (re-import session entry) -// - __nsApplyStyleUpdate (CSS HMR apply) -// - __nsGetLoadedModuleUrls (registry introspection) -// - (debug only) __NS_DISPATCH_HOT_EVENT__, -// __nsRunHmrDispose, __nsRunHmrPrune, -// __nsHasDeclinedModule -void InitializeHmrDevGlobals(v8::Isolate* isolate, v8::Local context); +// Dev host namespace installer +// +// Installs the single `__NS_DEV__` namespace object that carries every +// JS-callable dev primitive that any tooling can depend on. +// Idempotent per realm; safe to call from any place that has a fresh +// context + isolate scope. Installed on the realm's global object AND +// mirrored on globalThis. +// +// `__NS_DEV__` members: +// - configureRuntime(config) (import map + volatile patterns) +// - invalidateModules(urls) (registry + cache eviction) +// - kickstartPrefetch(urls, opts?) (parallel HTTP prewarm, list mode) +// - getLoadedModuleUrls() (registry introspection) +// - setDevBootComplete(value?) (boot-complete signal) +// - terminateAllWorkers() (main isolate only; see CallbackHandlers.h) +// - canonicalizeHttpUrlKey(url) (debug builds only; test diagnostic) +void InitializeHmrDevGlobals(v8::Isolate* isolate, v8::Local context, + bool isWorker); } // namespace tns diff --git a/test-app/runtime/src/main/cpp/MetadataNode.cpp b/test-app/runtime/src/main/cpp/MetadataNode.cpp index cb1213883..2974f4b94 100644 --- a/test-app/runtime/src/main/cpp/MetadataNode.cpp +++ b/test-app/runtime/src/main/cpp/MetadataNode.cpp @@ -1753,13 +1753,12 @@ bool MetadataNode::GetExtendLocation(v8::Isolate* isolate, string& extendLocatio fullPathToFile = "script"; } else { // ── Normalize srcFileName down to a path-like string ────────── - // The earlier logic assumed srcFileName was always - // `file:///.js` and stripped the - // scheme + app root before chopping a literal `.js`. HTTP ESM - // loading (HMR dev workflow) passes a full URL like + // srcFileName is not always `file:///.js`: + // HTTP ESM loading (HMR dev workflow) passes a full URL like // `http://127.0.0.1:5173/ns/core/...` with no `.js` suffix and - // no app-root prefix, so that arithmetic could yield an empty - // `fullPathToFile` and crash downstream on an empty token list. + // no app-root prefix, so naive scheme/app-root/`.js` stripping + // can yield an empty `fullPathToFile` and crash downstream on + // an empty token list. // // The logic below is shape-aware: // 1. Strip a leading URL scheme + authority (`file://`, @@ -1816,9 +1815,9 @@ bool MetadataNode::GetExtendLocation(v8::Isolate* isolate, string& extendLocatio std::vector pathParts; Util::SplitString(fullPathToFile, "_", pathParts); - // Pre-fix this was an unconditional `pathParts.back()` and - // SEGV'd when `fullPathToFile` was empty. Walk backwards - // for the last non-empty token; if none, use a sentinel. + // An unconditional `pathParts.back()` SEGVs when + // `fullPathToFile` is empty. Walk backwards for the last + // non-empty token; if none, use a sentinel. std::string lastPathPart; for (auto it = pathParts.rbegin(); it != pathParts.rend(); ++it) { if (!it->empty()) { diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp index 9149f2e3d..a5083fb8d 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp @@ -299,7 +299,7 @@ static bool HasUrlScheme(const std::string& spec) { } // Matches `url` against the `volatilePatterns` configured by Vite via -// `__nsConfigureRuntime({ volatilePatterns: [...] })` (substring match). +// `__NS_DEV__.configureRuntime({ volatilePatterns: [...] })` (substring match). // Android's HTTP loader does not consult this: it enforces volatility // structurally — a consume-once prefetch read plus eviction on HMR // invalidation (see HttpFetchText in HMRSupport.cpp). It is available for a @@ -788,7 +788,22 @@ size_t InvalidateModules(const std::vector& keys) { } } if (!urlsToEvict.empty()) { + // Second layer: drop stale HTTP bodies from the kickstart prewarm + // cache for every URL we just invalidated. Without this, the next + // `HttpFetchText` for an evicted URL would happily return a stale + // body a previous kickstart wave left in the cache, and V8 would + // compile that stale source — producing the "1 cycle behind" lag + // for edits with many transitive importers. EvictHttpModulePrefetchCacheUrls(urlsToEvict); + + // Third layer: any HTTP cache between the runtime and the dev server + // (OS URL cache, proxy, host-installed HttpResponseCache) is outside + // the runtime's direct control. Mark every invalidated key so the + // NEXT network fetch of that URL carries a unique `__ns_dev_nonce` + // query param — the cache sees a URL it has never stored and must go + // to origin. The nonce is transport-only; module identity stays the + // canonical URL. + MarkUrlsForCacheBust(urlsToEvict); } if (IsScriptLoadingLogEnabled()) { DEBUG_WRITE("[resolver][invalidate] requested=%lu removed=%lu", @@ -957,16 +972,12 @@ void InitializeImportMetaObject(Local context, Local module, Lo // Set import.meta.dirname property meta->CreateDataProperty(context, ArgConverter::ConvertToV8String(isolate, "dirname"), dirnameStr).Check(); - // Attach import.meta.hot for HMR — debug/dev builds only. In a release - // build the HMR client and dev-session globals are not installed (see the - // isDebuggable gate in Runtime::PrepareV8Runtime), so this per-module hot - // surface would be inert dead weight on every module. Gate it on - // isDebuggable so production modules carry only import.meta.url/dirname. - // Standard HMR code always guards with `if (import.meta.hot)`, so leaving - // it undefined in release is the conventional, safe behavior. - if (tns::IsDebuggable()) { - tns::InitializeImportMetaHot(isolate, context, meta, modulePath); - } + // NOTE: the runtime deliberately does NOT attach `import.meta.hot`. + // Hot contexts are HMR *policy* and are injected by the JS dev client + // (`@nativescript/vite` rewrites served module source to + // `import.meta.hot = __NS_HOT_REGISTRY__.createHotContext(id)`). + // Modules loaded outside a dev session see no hot object at all — + // standard HMR code always guards with `if (import.meta.hot)`. } // Helper function to check if a file exists and is a regular file @@ -1722,6 +1733,10 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( return scope.Escape(resolver->GetPromise()); } } + if (blobMod->GetStatus() == v8::Module::kErrored) { + resolver->Reject(context, blobMod->GetException()).Check(); + return scope.Escape(resolver->GetPromise()); + } resolver->Resolve(context, blobMod->GetModuleNamespace()).Check(); return scope.Escape(resolver->GetPromise()); } @@ -1787,6 +1802,22 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( return scope.Escape(resolver->GetPromise()); } } + // With top-level-await enabled, Evaluate() returns a promise instead of + // an empty MaybeLocal on throw; the module's errored state must be read + // from its status. Propagate the real exception so import() rejects + // (and the dev client can surface it) instead of resolving a + // half-evaluated namespace. + if (mod->GetStatus() == v8::Module::kErrored) { + v8::Local exception = mod->GetException(); + if (IsScriptLoadingLogEnabled()) { + v8::String::Utf8Value exc8(isolate, exception); + DEBUG_WRITE("[http-esm][dyn][eval][errored] %s: %s", canonical.c_str(), + *exc8 ? *exc8 : "(no message)"); + } + g_moduleRegistry.erase(canonical); + resolver->Reject(context, exception).Check(); + return scope.Escape(resolver->GetPromise()); + } resolver->Resolve(context, mod->GetModuleNamespace()).Check(); return scope.Escape(resolver->GetPromise()); } @@ -1854,6 +1885,16 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( } } + // Top-level-await semantics: a throwing module leaves Evaluate() with a + // (rejected) promise, so the errored state must be read from status. + if (module->GetStatus() == v8::Module::kErrored) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("ImportModuleDynamicallyCallback: Evaluation errored for '%s'", spec.c_str()); + } + resolver->Reject(context, module->GetException()).Check(); + return scope.Escape(resolver->GetPromise()); + } + resolver->Resolve(context, module->GetModuleNamespace()).Check(); if (IsScriptLoadingLogEnabled()) { DEBUG_WRITE("ImportModuleDynamicallyCallback: Successfully resolved '%s'", spec.c_str()); diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h index bc3e3a3cf..1b1f75299 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h @@ -23,9 +23,9 @@ extern thread_local std::unordered_map>& g_m // used by `ResolveModuleCallback`; the dev server's import-map JSON is parsed // at the V8 layer by the callers, which pass the flat entries here. // `SetVolatilePatterns` accepts a list of URL substrings that should always -// re-fetch (never serve from the speculative-prefetch cache). Both are applied -// via `__nsConfigureRuntime` / `__nsConfigureDevRuntime` at session start and -// again at every HMR graph version bump. +// re-fetch (never serve from the kickstart prewarm cache). Both are applied +// via `__NS_DEV__.configureRuntime` at session start and again at every HMR +// graph version bump. // Set the process-wide import map from the given flat (bare-specifier → URL) // entries. The callers hold the parsed import-map object and extract its @@ -46,17 +46,18 @@ void CleanupImportMapGlobals(); std::vector GetLoadedModuleUrls(); // Evict the given keys (canonical registry keys) from `g_moduleRegistry`. -// No-op if the key is missing. Used by `__nsInvalidateModules` and by -// the HMR cycle to drop stale modules before re-importing. +// No-op if the key is missing. Used by `__NS_DEV__.invalidateModules` and +// by the JS dev client's HMR cycle to drop stale modules before +// re-importing. void RemoveModuleFromRegistry(const std::string& canonicalKey); // Drop a list of keys + their HTTP cache entries in one pass. Returns the // number of registry entries removed. size_t InvalidateModules(const std::vector& keys); -// Compile + register-only path used by the speculative HTTP loader so that -// a module can be cached without being instantiated/evaluated. The caller -// is responsible for instantiation + evaluation on the JS thread. +// Fetch + compile + register path used when an HTTP(S) URL is loaded as a +// module entry point (see ModuleInternal). The caller is responsible for +// instantiation + evaluation on the JS thread. v8::MaybeLocal LoadHttpModuleForUrl(v8::Isolate* isolate, v8::Local context, const std::string& url, diff --git a/test-app/runtime/src/main/cpp/Runtime.cpp b/test-app/runtime/src/main/cpp/Runtime.cpp index 231fcecdd..256280592 100644 --- a/test-app/runtime/src/main/cpp/Runtime.cpp +++ b/test-app/runtime/src/main/cpp/Runtime.cpp @@ -32,7 +32,6 @@ #include "SimpleProfiler.h" #include "URLImpl.h" #include "HMRSupport.h" -#include "ModuleInternalCallbacks.h" #include "URLPatternImpl.h" #include "URLSearchParamsImpl.h" #include "Util.h" @@ -167,9 +166,9 @@ void SIG_handler(int sigNumber, siginfo_t* sigInfo, void* /*ucontext*/) { __android_log_print(ANDROID_LOG_FATAL, "TNS.Native", "=== end native crash ==="); - // Existing behavior: throw so JS-side error pipeline still reports. - // Note: throwing from a signal handler is technically UB, but the existing - // runtime has relied on it for years and works under libgcc/libunwind+itanium-abi. + // Throw so the JS-side error pipeline still reports the crash. + // Note: throwing from a signal handler is technically UB, but the runtime + // has relied on it for years and works under libgcc/libunwind+itanium-abi. stringstream msg; msg << "JNI Exception occurred (" << sigName << ").\n=======\nCheck the 'adb logcat' for additional information about " @@ -400,6 +399,13 @@ Runtime::~Runtime() { delete this->m_loopTimer; CallbackHandlers::RemoveIsolateEntries(m_isolate); if (m_isMainThread) { + // Clear process-wide dev-loader state (prewarm cache, cache-bust marks, + // boot flag, import map, vendor registry). Main isolate only: workers + // share these registries but the main isolate owns their lifetime — + // wiping them on worker teardown would race with the live main isolate. + tns::CleanupHMRGlobals(); + tns::CleanupImportMapGlobals(); + if (m_mainLooper_fd[0] != -1) { ALooper_removeFd(m_mainLooper, m_mainLooper_fd[0]); } @@ -898,22 +904,9 @@ Isolate* Runtime::PrepareV8Runtime(const string& filesPath, globalTemplate->Set(ArgConverter::ConvertToV8String(isolate, "Worker"), workerFuncTemplate); - // Main-thread-only HMR helper: `globalThis.__nsTerminateAllWorkers()`. - // Returns the count of workers terminated. HMR runtimes (e.g. - // @nativescript/vite) call this before re-bootstrapping the JS app so a - // cycle that re-runs a Worker-constructing scope doesn't leak a live - // worker. Workers never receive this global — a stuck worker shouldn't be - // able to take down its peers. - // - // Debug/dev only: it lets any in-process JS terminate every worker, so it - // must not ship in release. Gated on `isDebuggable` like the rest of the - // dev-global surface installed by `InitializeHmrDevGlobals` below. - if (isDebuggable) { - Local terminateAllWorkersTemplate = FunctionTemplate::New( - isolate, CallbackHandlers::TerminateAllWorkersCallback); - globalTemplate->Set(ArgConverter::ConvertToV8String(isolate, "__nsTerminateAllWorkers"), - terminateAllWorkersTemplate); - } + // Worker termination for dev tooling is exposed as + // `__NS_DEV__.terminateAllWorkers()`, installed by + // InitializeHmrDevGlobals below for the main isolate only. } /* @@ -940,20 +933,14 @@ Isolate* Runtime::PrepareV8Runtime(const string& filesPath, // it can be shared between runtimes. URLImpl::InstallBlobMethods(context); - // Install HMR + dev-session JS-callable globals on the main-thread - // isolate ONLY, and ONLY in a debuggable/dev build. Workers don't need - // (and would race on) the dev-session surface — the import-map, vendor - // registry, and per-module hot data all live on the main thread. - // - // The `isDebuggable` gate is required: this surface includes - // `__nsStartDevSession` / `__nsConfigureRuntime` which mutate the - // process-wide import map and can drive module loading, so it must be - // absent from release binaries. The actual remote fetch is independently - // gated by `DevFlags::IsRemoteUrlAllowed`, but the install itself should - // not happen in release. (`isDebuggable` is the PrepareV8Runtime param.) - if (m_isMainThread && isDebuggable) { - tns::InitializeHmrDevGlobals(isolate, context); - } + // Install the `__NS_DEV__` dev-loader namespace on EVERY isolate (main + // and worker) in EVERY build. The runtime's dev surface is mechanism + // only — the security boundary sits at the network layer + // (`DevFlags::IsRemoteUrlAllowed`), not at namespace installation; in a + // default-config release app the members are inert because every fetch + // they could trigger is denied. Workers get the same surface minus + // `terminateAllWorkers` (main-isolate only, see CallbackHandlers.h). + tns::InitializeHmrDevGlobals(isolate, context, /*isWorker=*/!m_isMainThread); m_objectManager->Init(isolate); m_module.Init(isolate, callingDir); diff --git a/test-app/runtime/src/main/cpp/URLImpl.h b/test-app/runtime/src/main/cpp/URLImpl.h index aadf939f5..56bcfee99 100644 --- a/test-app/runtime/src/main/cpp/URLImpl.h +++ b/test-app/runtime/src/main/cpp/URLImpl.h @@ -26,8 +26,8 @@ namespace tns { // registry (`URL.InternalAccessor`) used by the HMR loader, and the // `URL.prototype.searchParams` accessor. Must be called once per // realm AFTER `URL` and `URLSearchParams` constructors are installed. - // Behavior is bit-for-bit identical to the previously inlined script - // literal in `Runtime::Init`. + // The script literal lives here so every runtime (main and worker) + // shares one copy. static void InstallBlobMethods(v8::Local context); static void Ctor(const v8::FunctionCallbackInfo &args); diff --git a/test-app/runtime/src/main/cpp/Version.h b/test-app/runtime/src/main/cpp/Version.h index 79a15b872..2fb2e3c09 100644 --- a/test-app/runtime/src/main/cpp/Version.h +++ b/test-app/runtime/src/main/cpp/Version.h @@ -1,2 +1,2 @@ -#define NATIVE_SCRIPT_RUNTIME_VERSION "9.1.0-alpha.5" +#define NATIVE_SCRIPT_RUNTIME_VERSION "9.1.0-alpha.7" #define NATIVE_SCRIPT_RUNTIME_COMMIT_SHA "no commit sha was provided by build.gradle build" \ No newline at end of file diff --git a/test-app/runtime/src/main/cpp/WorkerWrapper.cpp b/test-app/runtime/src/main/cpp/WorkerWrapper.cpp index ba2e13d2f..011e2dadf 100644 --- a/test-app/runtime/src/main/cpp/WorkerWrapper.cpp +++ b/test-app/runtime/src/main/cpp/WorkerWrapper.cpp @@ -387,8 +387,7 @@ void WorkerWrapper::BackgroundLooper(std::shared_ptr self) { } } - // Deliver messages that were posted before the worker was ready - // (replaces the old Java Handshake + pendingWorkerMessages). + // Deliver messages that were posted before the worker was ready. DrainPendingTasks(); if (!isTerminating_ && !isClosing_) { @@ -437,8 +436,8 @@ void WorkerWrapper::BackgroundLooper(std::shared_ptr self) { if (runtime_ != nullptr) { try { // Java-side detach (GcListener.unsubscribe + runtimeCache.remove) - // must happen before the isolate is disposed, preserving the old - // WorkerThreadHandler -> TerminateWorkerCallback ordering. + // must happen before the isolate is disposed so Java never holds + // a runtime whose isolate is already gone. JEnv env; env.CallStaticVoidMethod(RUNTIME_CLASS, DETACH_WORKER_RUNTIME_METHOD_ID, runtimeId); } catch (NativeScriptException& ex) { diff --git a/test-app/runtime/src/main/cpp/WorkerWrapper.h b/test-app/runtime/src/main/cpp/WorkerWrapper.h index 5a0e2f863..0e0aa182d 100644 --- a/test-app/runtime/src/main/cpp/WorkerWrapper.h +++ b/test-app/runtime/src/main/cpp/WorkerWrapper.h @@ -94,9 +94,8 @@ class WorkerWrapper : public std::enable_shared_from_this { int lineno); /* - * Registry of live workers, keyed by workerId. Replaces the old - * CallbackHandlers::id2WorkerMap. Guarded by a mutex because the worker - * shutdown path posts cleanup from the worker thread. + * Registry of live workers, keyed by workerId. Guarded by a mutex + * because the worker shutdown path posts cleanup from the worker thread. */ static int NextWorkerId(); static std::shared_ptr GetById(int workerId); diff --git a/test-app/runtime/src/main/java/com/tns/AppConfig.java b/test-app/runtime/src/main/java/com/tns/AppConfig.java index 095f5d1f2..ee5446c50 100644 --- a/test-app/runtime/src/main/java/com/tns/AppConfig.java +++ b/test-app/runtime/src/main/java/com/tns/AppConfig.java @@ -25,7 +25,6 @@ protected enum KnownKeys { EnableLineBreakpoins("enableLineBreakpoints", false), EnableMultithreadedJavascript("enableMultithreadedJavascript", false), LogScriptLoading("logScriptLoading", false), - HttpModulePrefetch("httpModulePrefetch", false), HttpFetchUrlLog("httpFetchUrlLog", false); private final String name; @@ -88,9 +87,6 @@ public AppConfig(File appDir) { if (rootObject.has(KnownKeys.LogScriptLoading.getName())) { values[KnownKeys.LogScriptLoading.ordinal()] = rootObject.getBoolean(KnownKeys.LogScriptLoading.getName()); } - if (rootObject.has(KnownKeys.HttpModulePrefetch.getName())) { - values[KnownKeys.HttpModulePrefetch.ordinal()] = rootObject.getBoolean(KnownKeys.HttpModulePrefetch.getName()); - } if (rootObject.has(KnownKeys.HttpFetchUrlLog.getName())) { values[KnownKeys.HttpFetchUrlLog.ordinal()] = rootObject.getBoolean(KnownKeys.HttpFetchUrlLog.getName()); } @@ -214,11 +210,6 @@ public boolean getLogScriptLoading() { return (v instanceof Boolean) ? ((Boolean)v).booleanValue() : false; } - public boolean getHttpModulePrefetch() { - Object v = values[KnownKeys.HttpModulePrefetch.ordinal()]; - return (v instanceof Boolean) ? ((Boolean)v).booleanValue() : false; - } - public boolean getHttpFetchUrlLog() { Object v = values[KnownKeys.HttpFetchUrlLog.ordinal()]; return (v instanceof Boolean) ? ((Boolean)v).booleanValue() : false; diff --git a/test-app/runtime/src/main/java/com/tns/Runtime.java b/test-app/runtime/src/main/java/com/tns/Runtime.java index 761be779b..46fb2840e 100644 --- a/test-app/runtime/src/main/java/com/tns/Runtime.java +++ b/test-app/runtime/src/main/java/com/tns/Runtime.java @@ -294,19 +294,6 @@ public static boolean getLogScriptLoadingEnabled() { return false; } - // Expose httpModulePrefetch flag for native code without re-reading package.json. - // Default OFF: opt in via package.json "httpModulePrefetch": true. - public static boolean getHttpModulePrefetchEnabled() { - Runtime runtime = com.tns.Runtime.getCurrentRuntime(); - if (runtime != null && runtime.config != null && runtime.config.appConfig != null) { - return runtime.config.appConfig.getHttpModulePrefetch(); - } - if (staticConfiguration != null && staticConfiguration.appConfig != null) { - return staticConfiguration.appConfig.getHttpModulePrefetch(); - } - return false; - } - // Expose httpFetchUrlLog flag for native code without re-reading package.json. // Default OFF (per-fetch log volume is high). Opt in via package.json // "httpFetchUrlLog": true to diagnose HTTP module loader behavior. From 7b87797edab9e9c3d7ba6da6b7cf7e4754e10394 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Fri, 3 Jul 2026 17:24:53 -0700 Subject: [PATCH 08/11] test: bump shared runtime tests submodule for structured-clone worker guards The V8 ValueSerializer worker messaging on this branch legitimately does not throw for circular objects; the suite at 3a262b9 gates the legacy JSON-serializer expectation to JSC and runs structured-clone round-trip specs instead. --- test-app/app/src/main/assets/app/shared | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-app/app/src/main/assets/app/shared b/test-app/app/src/main/assets/app/shared index 0e030139e..3a262b979 160000 --- a/test-app/app/src/main/assets/app/shared +++ b/test-app/app/src/main/assets/app/shared @@ -1 +1 @@ -Subproject commit 0e030139e7273975106cbedd69681f55d2c2fbf2 +Subproject commit 3a262b979c6b84cdfe69cd495436a7088d016505 From ef02f07de3140821ca7aea8d9e90a659248cf71a Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sat, 4 Jul 2026 16:35:17 -0700 Subject: [PATCH 09/11] feat(runtime): __NS_DEV__.seedModuleBodies for batch prewarm seeding from the boot archive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The JS bootstrap downloads the dev server's /__ns_dev__/boot-archive (NDJSON of {url, body} entries) and seeds them directly into the one-shot prewarm cache consumed by HttpFetchText during V8's synchronous module walk — replacing hundreds of serial kickstart fetches with one payload. Mechanism only: the server computes the closure and bodies; the runtime stores them behind the same gates as kickstart prefetch. Returns { ok, seeded, bytes } so callers can fall back to kickstartPrefetch. [skip ci] --- .../assets/app/tests/testNsDevBoundary.mjs | 29 +++++-- test-app/runtime/src/main/cpp/HMRSupport.cpp | 86 +++++++++++++++++++ test-app/runtime/src/main/cpp/HMRSupport.h | 1 + 3 files changed, 111 insertions(+), 5 deletions(-) diff --git a/test-app/app/src/main/assets/app/tests/testNsDevBoundary.mjs b/test-app/app/src/main/assets/app/tests/testNsDevBoundary.mjs index 6850bc744..dbb80e45d 100644 --- a/test-app/app/src/main/assets/app/tests/testNsDevBoundary.mjs +++ b/test-app/app/src/main/assets/app/tests/testNsDevBoundary.mjs @@ -2,11 +2,12 @@ // // Pins the mechanism-only native contract: the runtime exposes exactly the // `__NS_DEV__` namespace (configureRuntime, invalidateModules, -// kickstartPrefetch, getLoadedModuleUrls, setDevBootComplete, -// terminateAllWorkers, and the debug-only canonicalizeHttpUrlKey), and -// nothing else. HMR *policy* — `import.meta.hot`, hot-data/accept/dispose -// registries, dev-session state, boot orchestration — lives in the JS dev -// client (@nativescript/vite), not in the runtime. +// kickstartPrefetch, seedModuleBodies, getLoadedModuleUrls, +// setDevBootComplete, terminateAllWorkers, and the debug-only +// canonicalizeHttpUrlKey), and nothing else. HMR *policy* — +// `import.meta.hot`, hot-data/accept/dispose registries, dev-session state, +// boot orchestration — lives in the JS dev client (@nativescript/vite), not +// in the runtime. describe("__NS_DEV__ dev-loader boundary", function () { it("exposes the __NS_DEV__ namespace with the core primitives", function () { @@ -15,6 +16,7 @@ describe("__NS_DEV__ dev-loader boundary", function () { expect(typeof dev.configureRuntime).toBe("function"); expect(typeof dev.invalidateModules).toBe("function"); expect(typeof dev.kickstartPrefetch).toBe("function"); + expect(typeof dev.seedModuleBodies).toBe("function"); expect(typeof dev.getLoadedModuleUrls).toBe("function"); expect(typeof dev.setDevBootComplete).toBe("function"); // Main isolate: worker termination is installed here (and ONLY here — @@ -22,6 +24,23 @@ describe("__NS_DEV__ dev-loader boundary", function () { expect(typeof dev.terminateAllWorkers).toBe("function"); }); + it("seedModuleBodies rejects invalid input without seeding", function () { + const dev = globalThis.__NS_DEV__; + const noArg = dev.seedModuleBodies(); + expect(noArg.ok).toBe(false); + expect(noArg.seeded).toBe(0); + const badEntries = dev.seedModuleBodies([ + null, + { body: "export {};" }, // no url + { url: "not-a-url", body: "export {};" }, // non-http scheme + { url: "http://127.0.0.1:5173/ns/m/src/app.css", body: "body{}" }, // non-JS shape + { url: "http://127.0.0.1:5173/ns/m/src/main", body: "" }, // empty body + ]); + expect(badEntries.ok).toBe(false); + expect(badEntries.seeded).toBe(0); + expect(badEntries.bytes).toBe(0); + }); + it("keeps the dev surface confined to __NS_DEV__ (no flat __ns* globals)", function () { // The contract is the single namespace object: no dev primitive is // reachable as a flat global, so tooling can feature-detect exactly diff --git a/test-app/runtime/src/main/cpp/HMRSupport.cpp b/test-app/runtime/src/main/cpp/HMRSupport.cpp index ce91f9927..21d3c7618 100644 --- a/test-app/runtime/src/main/cpp/HMRSupport.cpp +++ b/test-app/runtime/src/main/cpp/HMRSupport.cpp @@ -1402,6 +1402,91 @@ void KickstartHmrPrefetchCallback(const v8::FunctionCallbackInfo& inf buildResult(ok, fetched, elapsedMs); } +// `__NS_DEV__.seedModuleBodies(entries)` — batch prewarm-cache seeding. +// +// The JS bootstrap downloads `/__ns_dev__/boot-archive` (NDJSON of +// {url, body} lines) and hands the parsed entries here. Each entry lands in +// the one-shot prewarm cache (`g_prefetchCache`, consumed by `HttpFetchText` +// during V8's synchronous module walk), behind the same gates as a kickstart +// fetch. Mechanism only: the dev server computed the closure and produced +// the bodies; the runtime just stores them. +// +// Accepts Array<{ url, body }>. Returns { ok, seeded, bytes }; callers fall +// back to `kickstartPrefetch(urls)` when nothing was seeded. +void SeedModuleBodiesCallback(const v8::FunctionCallbackInfo& info) { + v8::Isolate* isolate = info.GetIsolate(); + v8::HandleScope scope(isolate); + v8::Local ctx = isolate->GetCurrentContext(); + + auto buildResult = [&](bool ok, size_t seeded, size_t bytes) { + v8::Local result = v8::Object::New(isolate); + (void)result->Set(ctx, ToV8String(isolate, "ok"), v8::Boolean::New(isolate, ok)); + (void)result->Set(ctx, ToV8String(isolate, "seeded"), + v8::Integer::NewFromUnsigned(isolate, (uint32_t)seeded)); + (void)result->Set(ctx, ToV8String(isolate, "bytes"), + v8::Number::New(isolate, (double)bytes)); + info.GetReturnValue().Set(result); + }; + + if (info.Length() < 1 || !info[0]->IsArray()) { + if (tns::IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[__NS_DEV__.seedModuleBodies] expected Array<{url, body}>"); + } + buildResult(false, 0, 0); + return; + } + + v8::Local arr = info[0].As(); + const uint32_t len = arr->Length(); + v8::Local urlKey = ToV8String(isolate, "url"); + v8::Local bodyKey = ToV8String(isolate, "body"); + + size_t seeded = 0; + size_t bytes = 0; + for (uint32_t i = 0; i < len; i++) { + v8::Local elemVal; + if (!arr->Get(ctx, i).ToLocal(&elemVal) || !elemVal->IsObject()) continue; + v8::Local elem = elemVal.As(); + + v8::Local urlVal; + if (!elem->Get(ctx, urlKey).ToLocal(&urlVal) || !urlVal->IsString()) continue; + v8::String::Utf8Value urlU8(isolate, urlVal); + if (!*urlU8) continue; + std::string url(*urlU8); + if (url.empty()) continue; + + // Same gates a kickstart fetch passes before it may populate the + // prewarm cache (scheme, JS-source shape, remote-URL allowlist). + if (!StartsWith(url, "http://") && !StartsWith(url, "https://")) continue; + if (!LooksLikeJsSourceUrl(url)) continue; + if (!IsRemoteUrlAllowed(url)) continue; + + v8::Local bodyVal; + if (!elem->Get(ctx, bodyKey).ToLocal(&bodyVal) || !bodyVal->IsString()) continue; + v8::String::Utf8Value bodyU8(isolate, bodyVal); + if (!*bodyU8) continue; + std::string body(*bodyU8); + if (body.empty()) continue; + + const size_t bodySize = body.size(); + // Overwrite unconditionally — the archive body is the authoritative + // fresh copy, mirroring the kickstart's overwrite semantics. + { + std::lock_guard lock(g_prefetchMutex); + g_prefetchCache[url] = std::move(body); + } + ++seeded; + bytes += bodySize; + } + + if (tns::IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[__NS_DEV__.seedModuleBodies] seeded=%lu bytes=%lu of %u entries", + (unsigned long)seeded, (unsigned long)bytes, len); + } + + buildResult(seeded > 0, seeded, bytes); +} + void GetLoadedModuleUrlsCallback(const v8::FunctionCallbackInfo& info) { v8::Isolate* isolate = info.GetIsolate(); v8::HandleScope scope(isolate); @@ -1468,6 +1553,7 @@ void InitializeHmrDevGlobals(v8::Isolate* isolate, v8::Local contex InstallDevFunction(isolate, context, dev, "configureRuntime", ConfigureDevRuntimeCallback); InstallDevFunction(isolate, context, dev, "invalidateModules", InvalidateModulesCallback); InstallDevFunction(isolate, context, dev, "kickstartPrefetch", KickstartHmrPrefetchCallback); + InstallDevFunction(isolate, context, dev, "seedModuleBodies", SeedModuleBodiesCallback); InstallDevFunction(isolate, context, dev, "getLoadedModuleUrls", GetLoadedModuleUrlsCallback); InstallDevFunction(isolate, context, dev, "setDevBootComplete", SetDevBootCompleteCallback); diff --git a/test-app/runtime/src/main/cpp/HMRSupport.h b/test-app/runtime/src/main/cpp/HMRSupport.h index d769e75e6..bb62e57fb 100644 --- a/test-app/runtime/src/main/cpp/HMRSupport.h +++ b/test-app/runtime/src/main/cpp/HMRSupport.h @@ -179,6 +179,7 @@ void MirrorGlobalOnGlobalThis(v8::Isolate* isolate, v8::Local conte // - configureRuntime(config) (import map + volatile patterns) // - invalidateModules(urls) (registry + cache eviction) // - kickstartPrefetch(urls, opts?) (parallel HTTP prewarm, list mode) +// - seedModuleBodies(entries) (batch prewarm seeding from the boot archive) // - getLoadedModuleUrls() (registry introspection) // - setDevBootComplete(value?) (boot-complete signal) // - terminateAllWorkers() (main isolate only; see CallbackHandlers.h) From 32edd29f6bb4d3f45170bed8a840b01abd080419 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 5 Jul 2026 17:47:46 -0700 Subject: [PATCH 10/11] chore: 9.1.0-alpha.8 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1c1d5069c..2922244e9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@nativescript/android", "description": "NativeScript for Android using v8", - "version": "9.1.0-alpha.7", + "version": "9.1.0-alpha.8", "repository": { "type": "git", "url": "https://github.com/NativeScript/android.git" From c35584b15c9bf04d24cc114b982654c98bc011a0 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Thu, 16 Jul 2026 11:30:31 -0700 Subject: [PATCH 11/11] feat: support native ES classes with lazy registration --- test-app/app/src/main/assets/app/mainpage.js | 1 + .../assets/app/tests/testNativeESClasses.js | 338 +++++++++++++++ .../src/main/assets/internal/ts_helpers.js | 11 + .../runtime/src/main/cpp/CallbackHandlers.cpp | 44 ++ .../runtime/src/main/cpp/CallbackHandlers.h | 12 + .../runtime/src/main/cpp/JsArgConverter.cpp | 17 + .../src/main/cpp/JsArgToArrayConverter.cpp | 14 + .../runtime/src/main/cpp/MetadataNode.cpp | 405 +++++++++++++++++- test-app/runtime/src/main/cpp/MetadataNode.h | 33 +- 9 files changed, 871 insertions(+), 4 deletions(-) create mode 100644 test-app/app/src/main/assets/app/tests/testNativeESClasses.js diff --git a/test-app/app/src/main/assets/app/mainpage.js b/test-app/app/src/main/assets/app/mainpage.js index fb03e6ba8..bbf80f278 100644 --- a/test-app/app/src/main/assets/app/mainpage.js +++ b/test-app/app/src/main/assets/app/mainpage.js @@ -42,6 +42,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..d013be256 --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testNativeESClasses.js @@ -0,0 +1,338 @@ +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_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_the_NativeClass_decorator_is_applied_it_should_be_a_noop", function () { + expect(typeof global.NativeClass).toBe("function"); + + const DecoratedButton = global.NativeClass(class DecoratedButton extends com.tns.tests.Button1 { + getIMAGE_ID_PROP() { + return "decorated"; + } + }); + + var button = new DecoratedButton(); + expect(button.getIMAGE_ID_PROP()).toBe("decorated"); + }); + + it("When_anonymous_es_classes_extend_native_types_each_should_get_a_distinct_proxy", function () { + var First = class extends java.lang.Object { + toString() { + return "first anonymous"; + } + }; + var Second = class extends java.lang.Object { + toString() { + return "second anonymous"; + } + }; + + 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/internal/ts_helpers.js b/test-app/app/src/main/assets/internal/ts_helpers.js index d1860dc47..788356b5f 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,14 @@ } } + // No-op decorator for plain ES classes extending native types. + // The runtime registers such classes lazily (on first construction, static usage or when + // passed to native APIs), so the decorator only exists so shared iOS/Android sources and + // non-transformed code keep working. + function NativeClass(target) { + return target; + } + Object.defineProperty(global, "__native", { value: __native }); Object.defineProperty(global, "__extends", { value: __extends }); Object.defineProperty(global, "__decorate", { value: __decorate }); @@ -174,4 +182,7 @@ global.JavaProxy = JavaProxy; } global.Interfaces = Interfaces; + if (!global.NativeClass) { + global.NativeClass = NativeClass; + } })() \ No newline at end of file diff --git a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp index 54506911a..e5f21ecf4 100644 --- a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp +++ b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp @@ -163,6 +163,50 @@ jclass CallbackHandlers::ResolveClass(Isolate *isolate, const string &baseClassN return globalRefToGeneratedClass; } +jclass CallbackHandlers::ResolveClass(Isolate *isolate, const string &baseClassName, + const string &fullClassName, + const vector &methodOverrides, + const vector &implementedInterfaces, + bool isInterface) { + JEnv env; + jclass globalRefToGeneratedClass = env.CheckForClassInCache(fullClassName); + + if (globalRefToGeneratedClass == nullptr) { + JniLocalRef javaBaseClassName(env.NewStringUTF(baseClassName.c_str())); + JniLocalRef javaFullClassName(env.NewStringUTF(fullClassName.c_str())); + + jobjectArray methodOverridesArr = GetJavaStringArray(env, methodOverrides.size()); + for (size_t i = 0; i < methodOverrides.size(); i++) { + JniLocalRef name(env.NewStringUTF(methodOverrides[i].c_str())); + env.SetObjectArrayElement(methodOverridesArr, i, name); + } + + jobjectArray implementedInterfacesArr = GetJavaStringArray(env, implementedInterfaces.size()); + for (size_t i = 0; i < implementedInterfaces.size(); i++) { + JniLocalRef name(env.NewStringUTF(implementedInterfaces[i].c_str())); + env.SetObjectArrayElement(implementedInterfacesArr, i, name); + } + + auto runtime = Runtime::GetRuntime(isolate); + + // create or load generated binding (java class) + jclass generatedClass = (jclass) env.CallObjectMethod(runtime->GetJavaRuntime(), + RESOLVE_CLASS_METHOD_ID, + (jstring) javaBaseClassName, + (jstring) javaFullClassName, + methodOverridesArr, + implementedInterfacesArr, + isInterface); + + globalRefToGeneratedClass = env.InsertClassIntoCache(fullClassName, generatedClass); + + env.DeleteGlobalRef(methodOverridesArr); + env.DeleteGlobalRef(implementedInterfacesArr); + } + + return globalRefToGeneratedClass; +} + // Called by ExtendMethodCallback when extending a class string CallbackHandlers::ResolveClassName(Isolate *isolate, jclass &clazz) { auto runtime = Runtime::GetRuntime(isolate); diff --git a/test-app/runtime/src/main/cpp/CallbackHandlers.h b/test-app/runtime/src/main/cpp/CallbackHandlers.h index ef5550adf..dcb950085 100644 --- a/test-app/runtime/src/main/cpp/CallbackHandlers.h +++ b/test-app/runtime/src/main/cpp/CallbackHandlers.h @@ -41,6 +41,18 @@ namespace tns { const v8::Local &implementationObject, bool isInterface); + /* + * ResolveClass variant with explicitly collected method override names and implemented + * interface names. Used for plain ES class extensions where the overrides span multiple + * prototype levels and are non-enumerable (so the implementationObject-based scan above + * cannot see them). + */ + static jclass ResolveClass(v8::Isolate *isolate, const std::string &baseClassName, + const std::string &fullClassName, + const std::vector &methodOverrides, + const std::vector &implementedInterfaces, + bool isInterface); + static std::string ResolveClassName(v8::Isolate *isolate, jclass &clazz); static v8::Local diff --git a/test-app/runtime/src/main/cpp/JsArgConverter.cpp b/test-app/runtime/src/main/cpp/JsArgConverter.cpp index e5d82f0bd..995f54761 100644 --- a/test-app/runtime/src/main/cpp/JsArgConverter.cpp +++ b/test-app/runtime/src/main/cpp/JsArgConverter.cpp @@ -1,5 +1,6 @@ #include "JsArgConverter.h" #include "ObjectManager.h" +#include "MetadataNode.h" #include "JniSignatureParser.h" #include "JsArgToArrayConverter.h" #include "ArgConverter.h" @@ -151,6 +152,22 @@ bool JsArgConverter::ConvertArg(const Local &arg, int index) { if (!success) { sprintf(buff, "Cannot convert string to %s at index %d", typeSignature.c_str(), index); } + } else if (arg->IsFunction() && + (typeSignature == "Ljava/lang/Class;" || typeSignature == "Ljava/lang/Object;") && + !MetadataNode::TryResolveClassCtorTypeName(m_isolate, arg.As()).empty()) { + // a native type ctor (or a plain ES class extending one - registered lazily here) + // passed where Java expects a java.lang.Class. Typed nulls (`SomeClass.null`) and other + // functions resolve to an empty name and keep flowing through the object branch below. + auto typeName = MetadataNode::TryResolveClassCtorTypeName(m_isolate, arg.As()); + JEnv env; + jclass clazz = env.FindClass(typeName); + success = clazz != nullptr; + if (success) { + // JEnv caches classes as global refs - mark as global so the dtor doesn't delete it + SetConvertedObject(index, clazz, true /* isGlobal */); + } else { + sprintf(buff, "Cannot convert function to %s at index %d", typeSignature.c_str(), index); + } } else if (arg->IsObject()) { auto context = m_isolate->GetCurrentContext(); auto jsObject = arg->ToObject(context).ToLocalChecked(); diff --git a/test-app/runtime/src/main/cpp/JsArgToArrayConverter.cpp b/test-app/runtime/src/main/cpp/JsArgToArrayConverter.cpp index e7d424cbb..11d3769e9 100644 --- a/test-app/runtime/src/main/cpp/JsArgToArrayConverter.cpp +++ b/test-app/runtime/src/main/cpp/JsArgToArrayConverter.cpp @@ -134,6 +134,20 @@ bool JsArgToArrayConverter::ConvertArg(Local context, const LocalIsFunction() && !MetadataNode::TryResolveClassCtorTypeName(isolate, arg.As()).empty()) { + // a native type ctor (or a plain ES class extending one - registered lazily here) + // marshals to its java.lang.Class. Typed nulls (`SomeClass.null`) and other functions + // resolve to an empty name and keep flowing through the object branch below. + auto typeName = MetadataNode::TryResolveClassCtorTypeName(isolate, arg.As()); + jclass clazz = env.FindClass(typeName); + if (clazz != nullptr) { + // JEnv caches classes as global refs - mark as global so the dtor doesn't delete it + SetConvertedObject(env, index, clazz, true /* isGlobal */); + success = true; + } else { + s << "Cannot marshal JavaScript function at index " << index + << " to Java type. Only native type constructors can be marshalled (to java.lang.Class)."; + } } else if (arg->IsObject()) { auto jsObj = arg->ToObject(context).ToLocalChecked(); diff --git a/test-app/runtime/src/main/cpp/MetadataNode.cpp b/test-app/runtime/src/main/cpp/MetadataNode.cpp index 2974f4b94..b3799573d 100644 --- a/test-app/runtime/src/main/cpp/MetadataNode.cpp +++ b/test-app/runtime/src/main/cpp/MetadataNode.cpp @@ -11,6 +11,7 @@ #include "Runtime.h" #include #include +#include #include #include #include @@ -147,7 +148,20 @@ bool MetadataNode::IsNodeTypeInterface() { } string MetadataNode::GetTypeMetadataName(Isolate* isolate, Local& value) { - auto data = GetTypeMetadata(isolate, value.As()); + if (value.IsEmpty() || !value->IsFunction()) { + throw NativeScriptException(string("Cannot resolve native type - the value is not a constructor function.")); + } + + auto func = value.As(); + auto data = TryGetTypeMetadata(isolate, func); + if (data == nullptr) { + // may be a not-yet-registered plain ES class extension + data = EnsureExtendedESClass(isolate, func); + } + + if (data == nullptr) { + throw NativeScriptException(string("Cannot resolve native type - the function does not stand for a native type or an extension of one.")); + } return data->name; } @@ -258,7 +272,22 @@ void MetadataNode::ClassAccessorGetterCallback(Local property, const Prope try { auto thiz = info.This(); auto isolate = info.GetIsolate(); - auto data = GetTypeMetadata(isolate, thiz.As()); + + if (thiz.IsEmpty() || !thiz->IsFunction()) { + throw NativeScriptException(string("The 'class' property may only be accessed on a native type or an extended class constructor function.")); + } + + auto func = thiz.As(); + auto data = TryGetTypeMetadata(isolate, func); + if (data == nullptr) { + // Plain ES class extension accessed statically before any instance was constructed + // (e.g. `MyView.class`) - lazily register it now + data = EnsureExtendedESClass(isolate, func); + } + + if (data == nullptr) { + throw NativeScriptException(string("Cannot resolve java.lang.Class - the function does not stand for a native type or an extension of one.")); + } auto value = CallbackHandlers::FindClass(isolate, data->name); info.GetReturnValue().Set(value); @@ -1051,6 +1080,310 @@ void MetadataNode::SetTypeMetadata(Isolate* isolate, Local value, Type V8SetPrivateValue(isolate, value, String::NewFromUtf8(isolate, "typemetadata").ToLocalChecked(), External::New(isolate, data)); } +MetadataNode::TypeMetadata* MetadataNode::TryGetTypeMetadata(Isolate* isolate, const Local& value) { + Local hiddenVal; + V8GetPrivateValue(isolate, value, String::NewFromUtf8(isolate, "typemetadata").ToLocalChecked(), hiddenVal); + + if (hiddenVal.IsEmpty() || !hiddenVal->IsExternal()) { + return nullptr; + } + + return reinterpret_cast(hiddenVal.As()->Value()); +} + +std::string MetadataNode::TryResolveClassCtorTypeName(Isolate* isolate, const Local& func) { + // A ctor function that had its `.null` accessed doubles as the typed null value for its + // type (see NullObjectAccessorGetterCallback - the marker private is set on the ctor and + // the ctor itself is returned). Such functions must marshal as typed nulls, not as + // java.lang.Class references. + Local nullNodeMarker; + V8GetPrivateValue(isolate, func, V8StringConstants::GetNullNodeName(isolate), nullNodeMarker); + if (!nullNodeMarker.IsEmpty()) { + return std::string(); + } + + auto typeMetadata = TryGetTypeMetadata(isolate, func); + + if (typeMetadata == nullptr) { + typeMetadata = EnsureExtendedESClass(isolate, func); + } + + return typeMetadata != nullptr ? typeMetadata->name : std::string(); +} + +namespace { +// short deterministic identifier so the same ES class gets the same proxy class name across +// application launches (keeps the DexFactory on-disk dex cache warm) without depending on +// line/column numbers that shift with unrelated code edits +std::string HashESClassId(const std::string& input) { + uint32_t hash = 2166136261u; + for (char c : input) { + hash ^= static_cast(c); + hash *= 16777619u; + } + + char buff[9]; + snprintf(buff, sizeof(buff), "%08x", hash); + return std::string(buff); +} + +std::string SanitizeESClassNamePart(const std::string& name) { + std::string result; + result.reserve(name.size()); + for (char c : name) { + bool isValid = isalpha(c) || isdigit(c) || c == '_'; + result += isValid ? c : '_'; + } + return result; +} + +// True only for genuine `class` syntax constructors. Function source text is the reliable +// discriminator: per spec, Function.prototype.toString for a class constructor reproduces the +// `class` declaration/expression source (possibly behind leading comments/whitespace, which V8 +// does not emit for the class case - the text starts with "class"). +bool IsESClassConstructor(v8::Isolate* isolate, const v8::Local& func) { + auto context = isolate->GetCurrentContext(); + v8::Local sourceText; + if (!func->FunctionProtoToString(context).ToLocal(&sourceText)) { + return false; + } + + auto source = tns::ArgConverter::ConvertToString(sourceText); + return source.compare(0, 5, "class") == 0; +} +} // namespace + +MetadataNode::TypeMetadata* MetadataNode::EnsureExtendedESClass(Isolate* isolate, Local ctorFunc) { + // Already registered - a native class ctor, a legacy `.extend()`-created ctor or an + // ES class ctor registered by a previous call + auto existingMetadata = TryGetTypeMetadata(isolate, ctorFunc); + if (existingMetadata != nullptr) { + return existingMetadata; + } + + auto context = isolate->GetCurrentContext(); + + // Only genuine `class` syntax constructors participate in lazy ES registration. Downleveled + // ES5 "classes" (TypeScript ES5 output, the JavaProxy decorator target, ts_helpers __extends + // children) have the same shape - an untagged function whose prototype chain reaches a native + // ctor - but must keep flowing through the legacy `.extend()` pipeline. + if (!IsESClassConstructor(isolate, ctorFunc)) { + return nullptr; + } + + // Walk the constructor prototype chain (mirrors the `class X extends Y` chain) and collect + // every plain (unregistered) ES constructor level until we reach a constructor holding type + // metadata. ES-registered ancestors are flattened into this registration; legacy + // `.extend()`-created ancestors are not supported and make this function bail out so callers + // preserve their old behavior. + std::vector> chainCtors; + Local current = ctorFunc; + TypeMetadata* baseTypeMetadata = nullptr; + while (true) { + chainCtors.push_back(current); + + Local parentValue = current->GetPrototype(); + if (parentValue.IsEmpty() || !parentValue->IsObject() || !parentValue->IsFunction()) { + // no native type in the chain - a plain JS class, leave it alone + return nullptr; + } + + auto parent = parentValue.As(); + auto parentMetadata = TryGetTypeMetadata(isolate, parent); + if (parentMetadata == nullptr) { + current = parent; + continue; + } + + if (parentMetadata->isESDerived) { + // Flatten: the parent's registered proxy class sits directly under the pure native + // base, so keep walking (collecting the parent's prototype for scanning) until we + // reach it + current = parent; + continue; + } + + auto cachedData = GetCachedExtendedClassData(isolate, parentMetadata->name); + if (cachedData.extendedCtorFunction != nullptr) { + // legacy `.extend()`-created ancestor - not supported for ES class chaining + return nullptr; + } + + baseTypeMetadata = parentMetadata; + break; + } + + string baseClassName = baseTypeMetadata->name; + auto node = GetOrCreate(baseClassName); + if (node == nullptr) { + return nullptr; + } + + uint8_t nodeType = s_metadataReader.GetNodeType(node->m_treeNode); + bool isInterface = s_metadataReader.IsNodeTypeInterface(nodeType); + + // Collect overridden method names level by level, most-derived first, so JS shadowing + // semantics carry over. ES class methods are non-enumerable, so unlike the legacy + // implementation-object scan we must use ALL_PROPERTIES. + std::vector methodOverrides; + std::vector implementedInterfaces; + robin_hood::unordered_set visitedNames; + std::string nativeClassName; + + auto prototypeKey = V8StringConstants::GetPrototype(isolate); + auto interfacesKey = ArgConverter::ConvertToV8String(isolate, "interfaces"); + auto nativeClassNameKey = ArgConverter::ConvertToV8String(isolate, "nativeClassName"); + auto propertyFilter = static_cast(PropertyFilter::ALL_PROPERTIES | PropertyFilter::SKIP_SYMBOLS); + + for (auto& levelCtor : chainCtors) { + Local protoValue; + if (!levelCtor->Get(context, prototypeKey).ToLocal(&protoValue) || protoValue.IsEmpty() || !protoValue->IsObject()) { + continue; + } + auto levelPrototype = protoValue.As(); + + Local propNames; + if (levelPrototype->GetOwnPropertyNames(context, propertyFilter).ToLocal(&propNames)) { + for (uint32_t i = 0; i < propNames->Length(); i++) { + Local nameValue; + if (!propNames->Get(context, i).ToLocal(&nameValue) || !nameValue->IsString()) { + continue; + } + + string name = ArgConverter::ConvertToString(nameValue.As()); + if (name == "constructor" || name == "super") { + continue; + } + + // skip names already handled by a more derived level (JS shadowing semantics) + if (!visitedNames.insert(name).second) { + continue; + } + + // inspect the descriptor instead of reading the property, so user-defined + // accessors are not invoked during registration + Local descriptor; + if (!levelPrototype->GetOwnPropertyDescriptor(context, nameValue.As()).ToLocal(&descriptor) + || descriptor.IsEmpty() || !descriptor->IsObject()) { + continue; + } + + Local methodValue; + if (descriptor.As()->Get(context, V8StringConstants::GetValue(isolate)).ToLocal(&methodValue) + && !methodValue.IsEmpty() && methodValue->IsFunction()) { + methodOverrides.push_back(name); + } + } + } + + // `static interfaces = [...]` - additional interfaces the proxy should implement + bool hasOwnInterfaces; + if (levelCtor->HasOwnProperty(context, interfacesKey).To(&hasOwnInterfaces) && hasOwnInterfaces) { + Local interfacesValue; + if (levelCtor->Get(context, interfacesKey).ToLocal(&interfacesValue) && interfacesValue->IsArray()) { + auto interfacesArr = interfacesValue.As(); + for (uint32_t i = 0; i < interfacesArr->Length(); i++) { + Local element; + if (!interfacesArr->Get(context, i).ToLocal(&element) || !element->IsFunction()) { + continue; + } + + auto interfaceName = GetTypeMetadataName(isolate, element); + interfaceName = Util::ReplaceAll(interfaceName, std::string("/"), std::string(".")); + if (std::find(implementedInterfaces.begin(), implementedInterfaces.end(), interfaceName) == implementedInterfaces.end()) { + implementedInterfaces.push_back(interfaceName); + } + } + } + } + + // `static nativeClassName = 'com.my.Thing'` - explicit proxy class name (most-derived wins) + if (nativeClassName.empty()) { + bool hasOwnNativeClassName; + if (levelCtor->HasOwnProperty(context, nativeClassNameKey).To(&hasOwnNativeClassName) && hasOwnNativeClassName) { + Local nativeClassNameValue; + if (levelCtor->Get(context, nativeClassNameKey).ToLocal(&nativeClassNameValue) && nativeClassNameValue->IsString()) { + nativeClassName = ArgConverter::ConvertToString(nativeClassNameValue.As()); + } + } + } + } + + // Compute the proxy class name + string fullClassName; + if (!nativeClassName.empty() && nativeClassName.find('.') != string::npos) { + fullClassName = nativeClassName; + } else if (isInterface) { + // interface extensions reuse the shared interface proxy, exactly like + // `new SomeInterface({...})` - all methods dispatch back to JS through the instance + fullClassName = node->m_implType; + } else { + string className; + auto jsClassName = ctorFunc->GetName(); + if (!jsClassName.IsEmpty() && jsClassName->IsString()) { + className = SanitizeESClassNamePart(ArgConverter::ConvertToString(jsClassName.As())); + } + if (className.empty()) { + className = "ESClass"; + } + + string scriptName; + auto scriptOrigin = ctorFunc->GetScriptOrigin(); + auto resourceName = scriptOrigin.ResourceName(); + if (!resourceName.IsEmpty() && resourceName->IsString()) { + scriptName = ArgConverter::ConvertToString(resourceName.As()); + } + + string extendNameAndLocation = "es" + HashESClassId(scriptName + "|" + baseClassName + "|" + className) + "_" + className; + string candidate = TNS_PREFIX + CreateFullClassName(baseClassName, extendNameAndLocation); + + // collision handling for distinct classes that produce the same deterministic name + // (e.g. a class factory evaluated multiple times in the same script) + fullClassName = candidate; + int suffix = 2; + while (GetCachedExtendedClassData(isolate, fullClassName).extendedCtorFunction != nullptr) { + fullClassName = candidate + "_" + std::to_string(suffix++); + } + } + + // Resolve (generate or load) the Java proxy class through the regular DexFactory pipeline + auto clazz = CallbackHandlers::ResolveClass(isolate, baseClassName, fullClassName, methodOverrides, implementedInterfaces, isInterface); + auto fullExtendedName = CallbackHandlers::ResolveClassName(isolate, clazz); + + // Tag the ES ctor the same way ExtendMethodCallback tags `.extend()`-created functions, so + // the rest of the runtime (construction, Java-initiated instantiation, `.class`, marshalling) + // treats it like any other extended class ctor + auto typeMetadata = new TypeMetadata(fullExtendedName, true /* isESDerived */); + SetTypeMetadata(isolate, ctorFunc, typeMetadata); + + // The ES class prototype acts as the implementation object. Mark it the way + // ExtendMethodCallback marks implementation objects, so GetImplementationObject (used + // during Java-initiated instantiation) can find it on the instance prototype chain. + Local ctorPrototypeValue; + if (ctorFunc->Get(context, prototypeKey).ToLocal(&ctorPrototypeValue) && ctorPrototypeValue->IsObject()) { + auto ctorPrototype = ctorPrototypeValue.As(); + auto implementationObjectPropertyName = V8StringConstants::GetClassImplementationObject(isolate); + Local hiddenVal; + V8GetPrivateValue(isolate, ctorPrototype, implementationObjectPropertyName, hiddenVal); + if (hiddenVal.IsEmpty()) { + V8SetPrivateValue(isolate, ctorPrototype, implementationObjectPropertyName, String::NewFromUtf8(isolate, fullExtendedName.c_str()).ToLocalChecked()); + } + } + + s_name2NodeCache.emplace(fullExtendedName, node); + + auto cache = GetMetadataNodeCache(isolate); + auto itCached = cache->ExtendedCtorFuncCache.find(fullExtendedName); + if (itCached == cache->ExtendedCtorFuncCache.end()) { + ExtendedClassCacheData cacheData(ctorFunc, fullExtendedName, node); + cache->ExtendedCtorFuncCache.emplace(fullExtendedName, cacheData); + } + + DEBUG_WRITE("EnsureExtendedESClass: registered %s (base %s)", fullExtendedName.c_str(), baseClassName.c_str()); + + return typeMetadata; +} + MetadataNode* MetadataNode::GetInstanceMetadata(Isolate* isolate, const Local& value) { MetadataNode* node = nullptr; auto cache = GetMetadataNodeCache(isolate); @@ -1129,6 +1462,30 @@ void MetadataNode::InterfaceConstructorCallback(const v8::FunctionCallbackInfo v8ExtendName; auto context = isolate->GetCurrentContext(); + // Plain ES class implementing an interface: `class Handler extends java.lang.Runnable {}` + // invokes this callback through `super()` with new.target set to the ES constructor and + // no implementation object argument. The methods live on the ES class prototype. + auto newTargetValue = info.NewTarget(); + if (!newTargetValue.IsEmpty() && newTargetValue->IsFunction()) { + auto newTargetFunc = newTargetValue.As(); + auto typeMetadata = TryGetTypeMetadata(isolate, newTargetFunc); + if (typeMetadata == nullptr) { + typeMetadata = EnsureExtendedESClass(isolate, newTargetFunc); + } + + if (typeMetadata != nullptr && typeMetadata->isESDerived) { + auto esImplementationObject = newTargetFunc->Get(context, V8StringConstants::GetPrototype(isolate)).ToLocalChecked().As(); + + SetInstanceMetadata(isolate, thiz, node); + thiz->SetInternalField(static_cast(ObjectManager::MetadataNodeKeys::CallSuper), True(isolate)); + V8SetPrivateValue(isolate, thiz, V8StringConstants::GetImplementationObject(isolate), esImplementationObject); + + ArgsWrapper esArgWrapper(info, ArgType::Interface); + CallbackHandlers::RegisterInstance(isolate, thiz, typeMetadata->name, esArgWrapper, esImplementationObject, true); + return; + } + } + if (info.Length() == 1) { if (!info[0]->IsObject()) { throw NativeScriptException(string("First argument must be implementation object")); @@ -1191,6 +1548,32 @@ void MetadataNode::ClassConstructorCallback(const v8::FunctionCallbackInfom_name; + // Plain ES class extension: `class MyView extends android.view.View {}` invokes this + // callback through `super(...)` with new.target set to the most derived ES constructor. + // Lazily register the ES class as an extended class and construct its proxy instead of + // the base class. + auto newTargetValue = info.NewTarget(); + if (!newTargetValue.IsEmpty() && newTargetValue->IsFunction()) { + auto newTargetFunc = newTargetValue.As(); + auto typeMetadata = TryGetTypeMetadata(isolate, newTargetFunc); + if (typeMetadata == nullptr) { + typeMetadata = EnsureExtendedESClass(isolate, newTargetFunc); + } + + if (typeMetadata != nullptr && typeMetadata->isESDerived) { + auto context = isolate->GetCurrentContext(); + auto implementationObject = newTargetFunc->Get(context, V8StringConstants::GetPrototype(isolate)).ToLocalChecked().As(); + + SetInstanceMetadata(isolate, thiz, node); + thiz->SetInternalField(static_cast(ObjectManager::MetadataNodeKeys::CallSuper), True(isolate)); + V8SetPrivateValue(isolate, thiz, V8StringConstants::GetImplementationObject(isolate), implementationObject); + + ArgsWrapper esArgWrapper(info, ArgType::Class); + CallbackHandlers::RegisterInstance(isolate, thiz, typeMetadata->name, esArgWrapper, implementationObject, false, className); + return; + } + } + SetInstanceMetadata(isolate, thiz, node); ArgsWrapper argWrapper(info, ArgType::Class); @@ -1578,6 +1961,24 @@ void MetadataNode::ExtendMethodCallback(const v8::FunctionCallbackInfoIsFunction()) { + auto thisFunc = thisValue.As(); + auto thisMetadata = TryGetTypeMetadata(info.GetIsolate(), thisFunc); + bool isESClassReceiver = (thisMetadata != nullptr && thisMetadata->isESDerived) || + (thisMetadata == nullptr && IsESClassConstructor(info.GetIsolate(), thisFunc)); + if (isESClassReceiver) { + throw NativeScriptException(string("Cannot call 'extend' on a class extension created with ES class syntax. Extend it with `class X extends Y {}` instead.")); + } + } + } + Local implementationObject; Local extendName; string extendLocation; diff --git a/test-app/runtime/src/main/cpp/MetadataNode.h b/test-app/runtime/src/main/cpp/MetadataNode.h index 71e7bfc05..9ff75c342 100644 --- a/test-app/runtime/src/main/cpp/MetadataNode.h +++ b/test-app/runtime/src/main/cpp/MetadataNode.h @@ -59,6 +59,15 @@ class MetadataNode { static std::string GetTypeMetadataName(v8::Isolate* isolate, v8::Local& value); + /* + * Resolves the Java class name a constructor function stands for, lazily registering a + * Java proxy class when the function is a plain ES `class X extends NativeType {}` that + * has not been registered yet. Returns an empty string when the function is not part of + * a native inheritance chain. Used when marshalling a constructor function to a Java + * `java.lang.Class` (or `java.lang.Object`) argument. + */ + static std::string TryResolveClassCtorTypeName(v8::Isolate* isolate, const v8::Local& func); + static void onDisposeIsolate(v8::Isolate* isolate); static MetadataReader* getMetadataReader(); @@ -129,8 +138,24 @@ class MetadataNode { static TypeMetadata* GetTypeMetadata(v8::Isolate* isolate, const v8::Local& value); + // Safe variant of GetTypeMetadata - returns nullptr when the function carries no type + // metadata (e.g. a plain ES class constructor) instead of crashing + static TypeMetadata* TryGetTypeMetadata(v8::Isolate* isolate, const v8::Local& value); + static void SetTypeMetadata(v8::Isolate* isolate, v8::Local value, TypeMetadata* data); + /* + * Lazily registers a Java proxy class for a plain ES `class X extends NativeType {}` + * constructor function (no `.extend()` call, no downleveling). Walks the constructor + * prototype chain to the native base, collects overridden method names from every ES + * level's prototype and implemented interfaces from `static interfaces = [...]`, resolves + * the proxy class through the regular DexFactory pipeline and tags the constructor the + * same way `.extend()` tags its result (typemetadata + ExtendedCtorFuncCache entry). + * Returns nullptr when ctorFunc is not part of a native inheritance chain or the chain + * goes through a legacy `.extend()`-created class. Idempotent. + */ + static TypeMetadata* EnsureExtendedESClass(v8::Isolate* isolate, v8::Local ctorFunc); + static std::string CreateFullClassName(const std::string& className, const std::string& extendNameAndLocation); static void MethodCallback(const v8::FunctionCallbackInfo& info); static void InterfaceConstructorCallback(const v8::FunctionCallbackInfo& info); @@ -231,12 +256,16 @@ class MetadataNode { }; struct TypeMetadata { - TypeMetadata(const std::string& _name) + TypeMetadata(const std::string& _name, bool _isESDerived = false) : - name(_name) { + name(_name), isESDerived(_isESDerived) { } std::string name; + + // true when the class was registered lazily from a plain ES + // `class X extends NativeType {}` constructor (see EnsureExtendedESClass) + bool isESDerived; }; struct CtorCacheData {