From 4104f0b7a308e769fa742fedf82fafa9ac1e31b3 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 7 Jun 2026 17:55:57 -0700 Subject: [PATCH] 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 + .../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 | 67 + .../runtime/src/main/cpp/CallbackHandlers.h | 15 + 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 + .../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 + 23 files changed, 5306 insertions(+), 252 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 0a8c79b00..71c30bf91 100644 --- a/test-app/app/src/main/assets/app/mainpage.js +++ b/test-app/app/src/main/assets/app/mainpage.js @@ -75,3 +75,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/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 709359a76..8ba130096 100644 --- a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp +++ b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp @@ -1616,6 +1616,73 @@ void CallbackHandlers::TerminateWorkerThread(Isolate *isolate) { isolate->TerminateExecution(); } +void CallbackHandlers::TerminateAllWorkersCallback(const v8::FunctionCallbackInfo &args) { + // Snapshot live worker IDs BEFORE invoking any terminate. Without the + // snapshot, calling `ClearWorkerPersistent` mid-iteration mutates + // `id2WorkerMap` and invalidates the iterator. + // + // We don't filter by isTerminated/closing status here. The Java-side + // `workerObjectTerminate` is idempotent (it checks `isTerminated` + // before queuing the TerminateThread message — Runtime.java:1670) + // so re-terminating a worker mid-shutdown is a safe no-op rather + // than a double-terminate. The diagnostic count we return below + // counts only IDs that survived the no-op gate. + Isolate* isolate = args.GetIsolate(); + HandleScope scope(isolate); + Local context = isolate->GetCurrentContext(); + + std::vector snapshot; + snapshot.reserve(CallbackHandlers::id2WorkerMap.size()); + for (auto& kv : CallbackHandlers::id2WorkerMap) { + snapshot.push_back(kv.first); + } + + int32_t terminatedCount = 0; + JEnv env; + auto mId = env.GetStaticMethodID(RUNTIME_CLASS, "workerObjectTerminate", "(I)V"); + + for (int workerId : snapshot) { + auto workerFound = CallbackHandlers::id2WorkerMap.find(workerId); + if (workerFound == CallbackHandlers::id2WorkerMap.end()) { + continue; + } + auto* persistent = workerFound->second; + if (persistent == nullptr || persistent->IsEmpty()) { + continue; + } + Local workerObj = Local::New(isolate, *persistent); + if (workerObj.IsEmpty()) { + continue; + } + + // Check whether this worker already had .terminate() called. + // Skip without counting if so. + Local isTerminated; + V8GetPrivateValue(isolate, workerObj, + ArgConverter::ConvertToV8String(isolate, "isTerminated"), + isTerminated); + if (!isTerminated.IsEmpty() && isTerminated->BooleanValue(isolate)) { + continue; + } + + V8SetPrivateValue( + isolate, workerObj, + ArgConverter::ConvertToV8String(isolate, "isTerminated"), + Boolean::New(isolate, true)); + + try { + env.CallStaticVoidMethod(RUNTIME_CLASS, mId, workerId); + CallbackHandlers::ClearWorkerPersistent(workerId); + ++terminatedCount; + } catch (...) { + // Swallow per-worker failures — the HMR caller cares about + // best-effort "terminate everything you can" semantics. + } + } + + args.GetReturnValue().Set(terminatedCount); +} + void CallbackHandlers::RemoveIsolateEntries(v8::Isolate *isolate) { for (auto &item: cache_) { if (item.second.isolate_ == isolate) { diff --git a/test-app/runtime/src/main/cpp/CallbackHandlers.h b/test-app/runtime/src/main/cpp/CallbackHandlers.h index d079d4730..703ad9eae 100644 --- a/test-app/runtime/src/main/cpp/CallbackHandlers.h +++ b/test-app/runtime/src/main/cpp/CallbackHandlers.h @@ -179,6 +179,21 @@ namespace tns { */ static void TerminateWorkerThread(v8::Isolate *isolate); + /* + * `__nsTerminateAllWorkers()` global, installed on the main-thread + * isolate only. Iterates `id2WorkerMap` and calls + * `Runtime.workerObjectTerminate(workerId)` for each live worker, + * then clears the persistent handle. Returns the number of workers + * actually terminated (already-terminated entries don't count) so + * the HMR JS client can log the count. + * + * The snapshot-first iteration copies the workerId set before + * invoking any terminate so concurrent ClearWorkerPersistent + * calls (from a worker self-terminating in parallel) can't + * invalidate our iterator. + */ + 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 aab1050ec..608d09d77 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); @@ -747,6 +876,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); + } } /* * Emulate a `WorkerGlobalScope` @@ -773,79 +919,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/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 1e7b2e8a1..21e52541c 100644 --- a/test-app/runtime/src/main/java/com/tns/DexFactory.java +++ b/test-app/runtime/src/main/java/com/tns/DexFactory.java @@ -31,6 +31,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; @@ -117,9 +137,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); @@ -194,6 +224,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 b610ddb0c..0065d7897 100644 --- a/test-app/runtime/src/main/java/com/tns/Runtime.java +++ b/test-app/runtime/src/main/java/com/tns/Runtime.java @@ -323,6 +323,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