From 2608b8bf4f24e19bc9e62764527eff049199a14b Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Fri, 22 May 2026 09:59:02 -0700 Subject: [PATCH 1/3] 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 iOS. * `import.meta.hot`: `data`, `accept`, `dispose`, `prune`, `decline`, `invalidate`, `on`/`off`/`send` event surface. * Dev-session globals (`__nsStartDevSession`, `__nsReloadDevApp`, `__nsInvalidateModules`, `__nsRunHmrDispose`, `__nsRunHmrPrune`, `__nsKickstartHmrPrefetch`, `__nsGetLoadedModuleUrls`, `__nsApplyStyleUpdate`, `__nsConfigureDevRuntime`, `__nsTerminateAllWorkers`). * Speculative HTTP module prefetch 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.mm` 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. * `NodeBuiltinsAndOptionalModulesTests.mjs`, `HttpEsmLoaderTests.js`, `hot-data-ext.{js,mjs}` test fixtures, plus integration wiring in `TestRunnerTests.swift` and the Jasmine boot harness. --- NativeScript/runtime/DevFlags.h | 14 + NativeScript/runtime/DevFlags.mm | 63 + NativeScript/runtime/HMRSupport.h | 321 +- NativeScript/runtime/HMRSupport.mm | 3201 ++++++++++++++++- NativeScript/runtime/ModuleInternal.h | 9 +- NativeScript/runtime/ModuleInternal.mm | 748 +++- .../runtime/ModuleInternalCallbacks.h | 33 +- .../runtime/ModuleInternalCallbacks.mm | 2920 ++++++++++----- NativeScript/runtime/Runtime.h | 5 +- NativeScript/runtime/Runtime.mm | 207 +- NativeScript/runtime/URLImpl.cpp | 82 + NativeScript/runtime/URLImpl.h | 11 +- NativeScript/runtime/Worker.h | 7 + NativeScript/runtime/Worker.mm | 74 + .../Jasmine/jasmine-2.0.1/boot.js | 9 + TestRunner/app/tests/HttpEsmLoaderTests.js | 323 +- TestRunner/app/tests/MethodCallsTests.js | 48 +- .../NodeBuiltinsAndOptionalModulesTests.mjs | 81 + TestRunner/app/tests/esm/hmr/hot-data-ext.js | 79 + TestRunner/app/tests/esm/hmr/hot-data-ext.mjs | 79 + TestRunnerTests/TestRunnerTests.swift | 160 +- 21 files changed, 7021 insertions(+), 1453 deletions(-) create mode 100644 TestRunner/app/tests/esm/hmr/hot-data-ext.js create mode 100644 TestRunner/app/tests/esm/hmr/hot-data-ext.mjs diff --git a/NativeScript/runtime/DevFlags.h b/NativeScript/runtime/DevFlags.h index 01533ae1..af81ac7a 100644 --- a/NativeScript/runtime/DevFlags.h +++ b/NativeScript/runtime/DevFlags.h @@ -12,6 +12,20 @@ namespace tns { // Controlled by package.json setting: "logScriptLoading": true|false 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 / nativescript.config: "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 / +// nativescript.config: "httpFetchUrlLog": true|false +bool IsHttpFetchUrlLogEnabled(); + // Security config // In debug mode (RuntimeConfig.IsDebug): always returns true. diff --git a/NativeScript/runtime/DevFlags.mm b/NativeScript/runtime/DevFlags.mm index 70d4c2bb..3921c3ab 100644 --- a/NativeScript/runtime/DevFlags.mm +++ b/NativeScript/runtime/DevFlags.mm @@ -1,6 +1,7 @@ #import #include "DevFlags.h" +#include "Helpers.h" #include "Runtime.h" #include "RuntimeConfig.h" #include @@ -13,6 +14,68 @@ bool IsScriptLoadingLogEnabled() { return value ? [value boolValue] : false; } +// HTTP module loader flags + +// Reads `httpModulePrefetch` from app config (default: DISABLED). +// +// Apps that want to opt in for testing can set: +// +// // nativescript.config.ts +// export default { +// httpModulePrefetch: true, +// } as NativeScriptConfig; +// +// Returning false here short-circuits both the cache lookup and the prefetch +// wave in HttpFetchText, restoring the pre-prefetcher behavior bit-for-bit. +bool IsHttpModulePrefetchEnabled() { + static std::once_flag s_initFlag; + static bool s_enabled = false; + std::call_once(s_initFlag, []() { + @autoreleasepool { + id value = Runtime::GetAppConfigValue("httpModulePrefetch"); + if (value && [value respondsToSelector:@selector(boolValue)]) { + s_enabled = [value boolValue]; + } + } + // Startup banner. Gated on the logScriptLoading flag so it stays silent + // by default — flip the flag in nativescript.config.ts when diagnosing + // why prefetch is or isn't engaging. + // + // [http-loader] prefetch=disabled ← expected default + // [http-loader] prefetch=enabled ← only if config opt-in + if (IsScriptLoadingLogEnabled()) { + Log(@"[http-loader] prefetch=%s shared-session=on hmr-kickstart=on", + s_enabled ? "enabled" : "disabled"); + } + }); + return s_enabled; +} + +// Default OFF because the volume is high (one line per fetch, hundreds per +// cold boot, hundreds per HMR refresh). Opt in via `nativescript.config.ts`: +// +// export default { +// httpFetchUrlLog: true, // turn on for diagnosis only +// … +// }; +bool IsHttpFetchUrlLogEnabled() { + static std::once_flag s_initFlag; + static bool s_enabled = false; + std::call_once(s_initFlag, []() { + @autoreleasepool { + id value = Runtime::GetAppConfigValue("httpFetchUrlLog"); + if (value && [value respondsToSelector:@selector(boolValue)]) { + s_enabled = [value boolValue]; + } + } + if (IsScriptLoadingLogEnabled()) { + Log(@"[http-loader] fetch-url-log=%s", + s_enabled ? "enabled" : "disabled"); + } + }); + return s_enabled; +} + // Security config static std::once_flag s_securityConfigInitFlag; diff --git a/NativeScript/runtime/HMRSupport.h b/NativeScript/runtime/HMRSupport.h index cf8af2a1..fe5942c6 100644 --- a/NativeScript/runtime/HMRSupport.h +++ b/NativeScript/runtime/HMRSupport.h @@ -11,6 +11,8 @@ template class Local; class Object; class Function; class Context; +class Value; +class Promise; } namespace tns { @@ -20,6 +22,7 @@ namespace tns { // 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. @@ -31,31 +34,331 @@ v8::Local GetOrCreateHotData(v8::Isolate* isolate, const std::string 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). Today the NS HMR pipeline does wholesale reboots rather than +// per-module pruning, so the registry is plumbed end-to-end but only fires +// when a future per-module HMR client explicitly drains it. +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); -// Attach a minimal import.meta.hot object to the provided import.meta object. -// The modulePath should be the canonical path used to key callback/data maps. +// `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 (used during HMR only) -// These are isolated here so ModuleInternalCallbacks stays lean. +// 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 HTTP(S) URLs for module registry keys. -// - Preserves versioning params for SFC endpoints (/@ns/sfc, /@ns/asm) -// - Drops cache-busting segments for /@ns/rt and /@ns/core -// - Drops query params for general app modules (/@ns/m) +// 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); -// Minimal text fetch for dev HTTP ESM loader. Returns true on 2xx with non-empty body. +// 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.mm for full design notes. bool HttpFetchText(const std::string& url, std::string& out, std::string& contentType, int& status); +// 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.mm). +// +// 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. The Angular HMR client does +// NOT call this today (its wholesale `__reboot_ng_modules__` model has no +// per-module prune step), but the runner is plumbed end-to-end so future +// per-module HMR clients have the entry point ready. +// +// 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.mm and HMRSupport.mm) +// +// These used to be duplicated as file-local statics in both translation +// units. Declared here once so the implementations live next to the HMR +// dev-session machinery that consumes them most heavily. + +// 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. Replaces ~650 lines of inline +// lambdas in Runtime::Init with a single call. 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/NativeScript/runtime/HMRSupport.mm b/NativeScript/runtime/HMRSupport.mm index 66cd3262..dc6b61f6 100644 --- a/NativeScript/runtime/HMRSupport.mm +++ b/NativeScript/runtime/HMRSupport.mm @@ -5,10 +5,16 @@ #include #include "DevFlags.h" +#include #include +#include #include #include +#include #include "Helpers.h" +#include "ModuleInternalCallbacks.h" +#include "Runtime.h" +#include "RuntimeConfig.h" // Use centralized dev flags helper for logging @@ -19,10 +25,194 @@ static inline bool StartsWith(const std::string& s, const char* 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; +} + // Per-module hot data and callbacks. Keyed by canonical module path. -static std::unordered_map> g_hotData; -static std::unordered_map>> g_hotAccept; -static std::unordered_map>> g_hotDispose; +// Heap-allocated (leaky singleton) to prevent V8 crash during __cxa_finalize_ranges. +// See g_moduleRegistry comment in ModuleInternalCallbacks.mm 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 std::unordered_map>> 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, tns::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, tns::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, tns::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, tns::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, tns::ToV8String(isolate, key), + tns::ToV8String(isolate, value.c_str())) + .FromMaybe(false); +} + +static bool IsSupportedDevSessionPlatform(const std::string& platform) { + return platform == "ios" || platform == "visionos"; +} + +static bool ApplyDevRuntimeConfigDictionary(NSDictionary* payload, + std::string* errorMessage) { + if (payload == nil || ![payload isKindOfClass:[NSDictionary class]]) { + if (errorMessage != nullptr) { + *errorMessage = "[__nsStartDevSession] runtime config payload must be an object"; + } + return false; + } + + id importMapValue = [payload objectForKey:@"importMap"]; + if (importMapValue == nil || ![importMapValue isKindOfClass:[NSDictionary class]]) { + if (errorMessage != nullptr) { + *errorMessage = "[__nsStartDevSession] runtime config payload is missing importMap"; + } + return false; + } + + NSError* importMapError = nil; + NSData* importMapData = + [NSJSONSerialization dataWithJSONObject:importMapValue options:0 error:&importMapError]; + if (importMapData == nil || importMapError != nil) { + if (errorMessage != nullptr) { + NSString* detail = importMapError.localizedDescription ?: @"unknown importMap serialization error"; + *errorMessage = std::string("[__nsStartDevSession] failed to serialize importMap: ") + + std::string([detail UTF8String] ?: "unknown importMap serialization error"); + } + return false; + } + + const void* importMapBytes = [importMapData bytes]; + NSUInteger importMapLength = [importMapData length]; + if (importMapBytes == nullptr || importMapLength == 0) { + if (errorMessage != nullptr) { + *errorMessage = "[__nsStartDevSession] runtime config importMap was empty"; + } + return false; + } + + std::string importMapJson(static_cast(importMapBytes), + static_cast(importMapLength)); + SetImportMap(importMapJson); + + std::vector patterns; + id volatilePatternsValue = [payload objectForKey:@"volatilePatterns"]; + if ([volatilePatternsValue isKindOfClass:[NSArray class]]) { + for (id value in (NSArray*)volatilePatternsValue) { + if (![value isKindOfClass:[NSString class]]) { + continue; + } + const char* utf8 = [(NSString*)value UTF8String]; + if (utf8 != nullptr && 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); @@ -36,6 +226,191 @@ static inline bool StartsWith(const std::string& s, const char* prefix) { 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, tns::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 ios or visionos"; + } + return false; + } + + *out = next; + return true; +} + +void ResetActiveDevSession() { + std::lock_guard lock(g_activeDevSessionMutex); + if (IsScriptLoadingLogEnabled() && g_activeDevSession.active) { + Log(@"[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()) { + Log(@"[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; + } + + @autoreleasepool { + NSData* jsonData = [NSData dataWithBytes:body.data() length:body.size()]; + if (jsonData == nil) { + if (errorMessage != nullptr) { + *errorMessage = "[__nsStartDevSession] failed to create runtime config data"; + } + return false; + } + + NSError* jsonError = nil; + id payload = [NSJSONSerialization JSONObjectWithData:jsonData options:kNilOptions error:&jsonError]; + if (payload == nil || ![payload isKindOfClass:[NSDictionary class]]) { + if (errorMessage != nullptr) { + NSString* detail = jsonError.localizedDescription ?: @"unknown runtime config parse error"; + *errorMessage = std::string("[__nsStartDevSession] failed to parse runtime config: ") + + std::string([detail UTF8String] ?: "unknown runtime config parse error"); + } + return false; + } + + if (!ApplyDevRuntimeConfigDictionary((NSDictionary*)payload, errorMessage)) { + return false; + } + } + + if (IsScriptLoadingLogEnabled()) { + Log(@"[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()) { + Log(@"[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()) { + Log(@"[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)); @@ -46,6 +421,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); @@ -68,110 +448,849 @@ void RegisterHotDispose(v8::Isolate* isolate, const std::string& key, v8::Local< return out; } -void InitializeImportMetaHot(v8::Isolate* isolate, - v8::Local context, - v8::Local importMeta, - const std::string& modulePath) { - using v8::Function; +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 observes `tryCatch.HasCaught()` and + // `result.ToLocal(...)` regardless of verbose mode — these mirror the + // dispose/prune dispatcher patterns elsewhere in this file (lines 664, + // 780) and the original pre-session `DispatchHotEvent` behavior. The + // round-7 fast-path variant that skipped these calls broke HMR even + // though `~TryCatch` resets state on destruction; preserving the + // observation pattern is the safest contract. + // + // All `Log()` calls are gated behind `verbose` so default-mode dev + // sessions are quiet; the per-listener int counters are practically + // free and feed the verbose-only summary line. Reproducing the verbose + // output requires `logScriptLoading: true` in `nativescript.config.ts`. + // The summary collapses "did any listener match?" into one line — the + // single most informative signal during HMR triage (see round 7 in + // `LATEST-05-07-2026-HMR_ANGULAR_DEBUG_SESSION.md`). + 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); + Log(@"[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)"; + Log(@"[import.meta.hot] Listener #%d for '%s' returned: %s", idx, event.c_str(), s.c_str()); + } + } + } + } + idx++; + } + if (verbose) { + Log(@"[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::Object; - using v8::String; using v8::Value; - // Ensure context scope for property creation - v8::HandleScope scope(isolate); - - // Helper to capture key in function data - auto makeKeyData = [&](const std::string& key) -> Local { - return tns::ToV8String(isolate, key.c_str()); - }; - - // accept([deps], cb?) — we register cb if provided; deps ignored for now - auto acceptCb = [](const FunctionCallbackInfo& info) { + // 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(); - Local data = info.Data(); - std::string key; - if (!data.IsEmpty()) { - v8::String::Utf8Value s(iso, data); - key = *s ? *s : ""; + v8::Local ctx = iso->GetCurrentContext(); + + if (info.Length() < 1 || !info[0]->IsString()) { + info.GetReturnValue().Set(v8::Integer::New(iso, -1)); + return; } - v8::Local cb; - if (info.Length() >= 1 && info[0]->IsFunction()) { - cb = info[0].As(); - } else if (info.Length() >= 2 && info[1]->IsFunction()) { - cb = info[1].As(); + + v8::String::Utf8Value eventName(iso, info[0]); + std::string event = *eventName ? *eventName : ""; + if (event.empty()) { + info.GetReturnValue().Set(v8::Integer::New(iso, -1)); + return; } - if (!cb.IsEmpty()) { - RegisterHotAccept(iso, key, cb); + + v8::Local data = info.Length() > 1 ? info[1] : v8::Undefined(iso).As(); + + auto callbacks = GetHotEventListeners(iso, event); + + if (tns::IsScriptLoadingLogEnabled()) { + Log(@"[import.meta.hot] Dispatching event '%s' to %d listener(s)", event.c_str(), (int)callbacks.size()); } - // Return undefined - info.GetReturnValue().Set(v8::Undefined(iso)); + + DispatchHotEvent(iso, ctx, event, data); + info.GetReturnValue().Set(v8::Integer::New(iso, (int)callbacks.size())); }; - // dispose(cb) — register disposer - auto disposeCb = [](const FunctionCallbackInfo& info) { + // __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(); - Local data = info.Data(); - std::string key; - if (!data.IsEmpty()) { v8::String::Utf8Value s(iso, data); key = *s ? *s : ""; } - if (info.Length() >= 1 && info[0]->IsFunction()) { - RegisterHotDispose(iso, key, info[0].As()); + v8::Local ctx = iso->GetCurrentContext(); + v8::Local result = v8::Object::New(iso); + for (const auto& kv : g_hotEventListeners) { + v8::Local name = tns::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(v8::Undefined(iso)); + info.GetReturnValue().Set(result); }; + + v8::Local global = context->Global(); + v8::Local dispatchFn = v8::Function::New(context, dispatchCb).ToLocalChecked(); + global->CreateDataProperty(context, tns::ToV8String(isolate, "__NS_DISPATCH_HOT_EVENT__"), dispatchFn).Check(); + v8::Local listFn = v8::Function::New(context, listCb).ToLocalChecked(); + global->CreateDataProperty(context, tns::ToV8String(isolate, "__nsListHotEventListeners"), listFn).Check(); +} - // decline() — mark declined (no-op for now) - auto declineCb = [](const FunctionCallbackInfo& info) { - info.GetReturnValue().Set(v8::Undefined(info.GetIsolate())); - }; +namespace { - // invalidate() — no-op for now - auto invalidateCb = [](const FunctionCallbackInfo& info) { - info.GetReturnValue().Set(v8::Undefined(info.GetIsolate())); - }; +// 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; - Local hot = Object::New(isolate); - // Stable flags - hot->CreateDataProperty(context, tns::ToV8String(isolate, "data"), - GetOrCreateHotData(isolate, modulePath)).Check(); - hot->CreateDataProperty(context, tns::ToV8String(isolate, "prune"), - v8::Boolean::New(isolate, false)).Check(); - // Methods - hot->CreateDataProperty( - context, tns::ToV8String(isolate, "accept"), - v8::Function::New(context, acceptCb, makeKeyData(modulePath)).ToLocalChecked()).Check(); - hot->CreateDataProperty( - context, tns::ToV8String(isolate, "dispose"), - v8::Function::New(context, disposeCb, makeKeyData(modulePath)).ToLocalChecked()).Check(); - hot->CreateDataProperty( - context, tns::ToV8String(isolate, "decline"), - v8::Function::New(context, declineCb, makeKeyData(modulePath)).ToLocalChecked()).Check(); - hot->CreateDataProperty( - context, tns::ToV8String(isolate, "invalidate"), - v8::Function::New(context, invalidateCb, makeKeyData(modulePath)).ToLocalChecked()).Check(); + // 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; + } - // Attach to import.meta - importMeta->CreateDataProperty( - context, tns::ToV8String(isolate, "hot"), - hot).Check(); -} + if (targetKeys.empty()) return 0; -// ───────────────────────────────────────────────────────────── -// Dev HTTP loader helpers + HandleScope handleScope(isolate); + int executed = 0; -std::string CanonicalizeHttpUrlKey(const std::string& url) { - if (!(StartsWith(url, "http://") || StartsWith(url, "https://"))) { - return url; + 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); + Log(@"%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; + } } - // Drop fragment entirely - size_t hashPos = url.find('#'); - std::string noHash = (hashPos == std::string::npos) ? url : url.substr(0, hashPos); - // Locate path start and query start - size_t schemePos = noHash.find("://"); - if (schemePos == std::string::npos) { - // Unexpected shape; fall back to removing whole query + 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`. + // Mirrors `InitializeHotEventDispatcher`'s exposure pattern. Used by + // `@nativescript/vite`'s Angular HMR client to drain `import.meta.hot.dispose` + // callbacks immediately before `__reboot_ng_modules__`. + // + // The `keys` argument lets future HMR clients drain only specific modules + // (e.g. for per-module hot replacement). Today's Angular client passes no + // arg so the runtime drains everything — accurate to the wholesale-reboot + // semantics of `__reboot_ng_modules__` (the entire JS realm's side-effect + // tree is being torn down). + 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, + tns::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`. The Angular HMR client doesn't call + // this today (its wholesale `__reboot_ng_modules__` model has no per-module + // prune step), but the runner is plumbed end-to-end so future per-module + // HMR clients can drain prune callbacks at the right moment. + 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, + tns::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, + tns::ToV8String(isolate, "__nsHasDeclinedModule"), + fn).Check(); +} + +void InitializeImportMetaHot(v8::Isolate* isolate, + v8::Local context, + v8::Local importMeta, + const std::string& modulePath) { + using v8::Function; + using v8::FunctionCallbackInfo; + using v8::Local; + using v8::Object; + using v8::String; + using v8::Value; + + // Ensure context scope for property creation + v8::HandleScope scope(isolate); + + // Canonicalize key to ensure per-module hot.data persists across HMR URLs. + // Important: this must NOT affect the HTTP loader cache key; otherwise HMR fetches + // can collapse onto an already-evaluated module and no update occurs. + auto canonicalHotKey = [&](const std::string& in) -> std::string { + // Unwrap file://http(s)://... + std::string s = in; + if (StartsWith(s, "file://http://") || StartsWith(s, "file://https://")) { + s = s.substr(strlen("file://")); + } + + const bool isHttpUrl = StartsWith(s, "http://") || StartsWith(s, "https://"); + if (isHttpUrl) { + // Preserve meaningful dev-endpoint query identity (for example /ns/core?p=...) + // while still dropping cache-busters and canonicalizing versioned bridge URLs. + s = CanonicalizeHttpUrlKey(s); + } + + // Drop fragment + size_t hashPos = s.find('#'); + if (hashPos != std::string::npos) s = s.substr(0, hashPos); + + std::string noQuery = s; + std::string suffix; + if (!isHttpUrl) { + size_t qPos = s.find('?'); + noQuery = (qPos == std::string::npos) ? s : s.substr(0, qPos); + } + + // If it's an http(s) URL, normalize only the path portion below. + size_t schemePos = noQuery.find("://"); + size_t pathStart = (schemePos == std::string::npos) ? 0 : noQuery.find('/', schemePos + 3); + if (pathStart == std::string::npos) { + // No path; return without query + return noQuery; + } + + std::string origin = noQuery.substr(0, pathStart); + std::string pathAndSuffix = noQuery.substr(pathStart); + if (isHttpUrl) { + size_t qPos = pathAndSuffix.find('?'); + if (qPos != std::string::npos) { + suffix = pathAndSuffix.substr(qPos); + pathAndSuffix = pathAndSuffix.substr(0, qPos); + } + } + std::string path = pathAndSuffix; + + // Normalize NS HMR virtual module paths: + // /ns/m/__ns_hmr__// -> /ns/m/ + auto normalizeHmrVirtualPath = [&](const char* prefix) { + size_t prefixLen = strlen(prefix); + if (path.compare(0, prefixLen, prefix) != 0) { + return false; + } + + size_t nextSlash = path.find('/', prefixLen); + if (nextSlash == std::string::npos) { + return false; + } + + path = std::string("/ns/m/") + path.substr(nextSlash + 1); + return true; + }; + + // Keep import.meta.hot.data stable across both live-tagged and boot-tagged HMR URLs. + if (!normalizeHmrVirtualPath("/ns/m/__ns_boot__/b1/__ns_hmr__/")) { + normalizeHmrVirtualPath("/ns/m/__ns_hmr__/"); + } + + auto normalizeBridge = [&](const char* needle) { + size_t nlen = strlen(needle); + if (path.compare(0, nlen, needle) != 0) return; + if (path.size() == nlen) return; + if (path.size() <= nlen + 1 || path[nlen] != '/') return; + + size_t i = nlen + 1; + size_t j = i; + while (j < path.size() && std::isdigit(static_cast(path[j]))) { + j++; + } + if (j == i) return; + if (j != path.size()) return; + + path = std::string(needle); + }; + + normalizeBridge("/ns/rt"); + normalizeBridge("/ns/core"); + + // Normalize common script extensions so `/foo` and `/foo.ts` share hot.data. + const char* exts[] = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"}; + for (auto ext : exts) { + if (EndsWith(path, ext)) { + path = path.substr(0, path.size() - strlen(ext)); + break; + } + } + + // Also drop `.vue`? No — SFC endpoints should stay distinct. + return origin + path + suffix; + }; + + const std::string key = canonicalHotKey(modulePath); + if (tns::IsScriptLoadingLogEnabled()) { + bool isReload = (g_hotData.find(key) != g_hotData.end()); + Log(@"[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 tns::ToV8String(isolate, k.c_str()); + }; + + // accept([deps], cb?) — we register cb if provided; deps ignored for now + auto acceptCb = [](const FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + Local data = info.Data(); + std::string key; + if (!data.IsEmpty()) { + v8::String::Utf8Value s(iso, data); + key = *s ? *s : ""; + } + v8::Local cb; + if (info.Length() >= 1 && info[0]->IsFunction()) { + cb = info[0].As(); + } else if (info.Length() >= 2 && info[1]->IsFunction()) { + cb = info[1].As(); + } + if (!cb.IsEmpty()) { + RegisterHotAccept(iso, key, cb); + } + // Return undefined + info.GetReturnValue().Set(v8::Undefined(iso)); + }; + + // dispose(cb) — register disposer + auto disposeCb = [](const FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + Local data = info.Data(); + std::string key; + if (!data.IsEmpty()) { v8::String::Utf8Value s(iso, data); key = *s ? *s : ""; } + if (info.Length() >= 1 && info[0]->IsFunction()) { + RegisterHotDispose(iso, key, info[0].As()); + } + info.GetReturnValue().Set(v8::Undefined(iso)); + }; + + // prune(cb) — register a callback that fires when this module is removed + // from the dep graph (NOT on every replacement — that's `dispose`). Today + // the NS HMR pipeline does wholesale reboots so prune callbacks rarely + // fire, but the registry is plumbed end-to-end so a future per-module + // HMR client can drain `g_hotPrune` via `__nsRunHmrPrune`. + auto pruneCb = [](const FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + Local data = info.Data(); + std::string key; + if (!data.IsEmpty()) { v8::String::Utf8Value s(iso, data); key = *s ? *s : ""; } + if (info.Length() >= 1 && info[0]->IsFunction()) { + RegisterHotPrune(iso, key, info[0].As()); + } + info.GetReturnValue().Set(v8::Undefined(iso)); + }; + + // decline() — mark this module as not hot-updateable (Vite spec). Adds the + // canonical key to `g_hotDeclined`; the HMR client checks this set via + // `__nsHasDeclinedModule(updatedKeys)` before applying an update and + // converts the cycle into a full reload (`__nsReloadDevApp`) on a hit. + auto declineCb = [](const FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + Local data = info.Data(); + std::string key; + if (!data.IsEmpty()) { v8::String::Utf8Value s(iso, data); key = *s ? *s : ""; } + if (!key.empty()) { + MarkHotDeclined(key); + if (tns::IsScriptLoadingLogEnabled()) { + Log(@"[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 right invalidate + + // re-import dance). The optional `message` argument is logged for the + // common Analog HMR fallback case (`'Component HMR failed, reloading'`), + // which used to silently no-op. + // + // We invoke `__nsReloadDevApp` from a microtask so the user's current + // execution stack (which contains the `invalidate()` call site) finishes + // before the runtime tears down for reload — calling synchronously would + // try to re-bootstrap from inside an in-flight callback. + auto invalidateCb = [](const FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + Local data = info.Data(); + std::string key; + if (!data.IsEmpty()) { v8::String::Utf8Value s(iso, data); key = *s ? *s : ""; } + + std::string message; + if (info.Length() >= 1 && info[0]->IsString()) { + v8::String::Utf8Value m(iso, info[0]); + if (*m) message = *m; + } + if (tns::IsScriptLoadingLogEnabled()) { + Log(@"[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, tns::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, tns::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, tns::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); + Log(@"[import.meta.hot.send] handler threw: %s", *m ? *m : "(unknown)"); + } + info.GetReturnValue().Set(v8::Undefined(iso)); + }; + + Local hot = Object::New(isolate); + // Stable flags + hot->CreateDataProperty(context, tns::ToV8String(isolate, "data"), + GetOrCreateHotData(isolate, key)).Check(); + // Methods + hot->CreateDataProperty( + context, tns::ToV8String(isolate, "accept"), + v8::Function::New(context, acceptCb, makeKeyData(key)).ToLocalChecked()).Check(); + hot->CreateDataProperty( + context, tns::ToV8String(isolate, "dispose"), + v8::Function::New(context, disposeCb, makeKeyData(key)).ToLocalChecked()).Check(); + hot->CreateDataProperty( + context, tns::ToV8String(isolate, "prune"), + v8::Function::New(context, pruneCb, makeKeyData(key)).ToLocalChecked()).Check(); + hot->CreateDataProperty( + context, tns::ToV8String(isolate, "decline"), + v8::Function::New(context, declineCb, makeKeyData(key)).ToLocalChecked()).Check(); + hot->CreateDataProperty( + context, tns::ToV8String(isolate, "invalidate"), + v8::Function::New(context, invalidateCb, makeKeyData(key)).ToLocalChecked()).Check(); + hot->CreateDataProperty( + context, tns::ToV8String(isolate, "on"), + v8::Function::New(context, onCb, makeKeyData(key)).ToLocalChecked()).Check(); + hot->CreateDataProperty( + context, tns::ToV8String(isolate, "off"), + v8::Function::New(context, offCb, makeKeyData(key)).ToLocalChecked()).Check(); + hot->CreateDataProperty( + context, tns::ToV8String(isolate, "send"), + v8::Function::New(context, sendCb, makeKeyData(key)).ToLocalChecked()).Check(); + + // Attach to import.meta + importMeta->CreateDataProperty( + context, tns::ToV8String(isolate, "hot"), + hot).Check(); +} + +// ───────────────────────────────────────────────────────────── +// HTTP loader helpers + +std::string CanonicalizeHttpUrlKey(const std::string& url) { + // Some loaders wrap HTTP module URLs as file://http(s)://... + std::string normalizedUrl = url; + if (StartsWith(normalizedUrl, "file://http://") || StartsWith(normalizedUrl, "file://https://")) { + normalizedUrl = normalizedUrl.substr(strlen("file://")); + } + if (!(StartsWith(normalizedUrl, "http://") || StartsWith(normalizedUrl, "https://"))) { + return normalizedUrl; + } + // Drop fragment entirely + size_t hashPos = normalizedUrl.find('#'); + std::string noHash = (hashPos == std::string::npos) ? normalizedUrl : normalizedUrl.substr(0, hashPos); + + // Locate path start and query start + size_t schemePos = noHash.find("://"); + if (schemePos == std::string::npos) { + // Unexpected shape; fall back to removing whole query size_t q = noHash.find('?'); return (q == std::string::npos) ? noHash : noHash.substr(0, q); } @@ -184,10 +1303,10 @@ void InitializeImportMetaHot(v8::Isolate* isolate, std::string originAndPath = (qPos == std::string::npos) ? noHash : noHash.substr(0, qPos); std::string query = (qPos == std::string::npos) ? std::string() : noHash.substr(qPos + 1); - // Normalize bridge endpoints to keep a single realm across HMR updates: + // Normalize bridge endpoints to keep a single realm across reloads: // - /ns/rt/ -> /ns/rt // - /ns/core/ -> /ns/core - // Preserve query params (e.g. /ns/core?p=...) as part of module identity. + // Preserve query params (e.g. /ns/core?p=...), except for internal cache-busters (import, t, v), as part of module identity. { std::string pathOnly = originAndPath.substr(pathStart); auto normalizeBridge = [&](const char* needle) { @@ -213,9 +1332,90 @@ void InitializeImportMetaHot(v8::Isolate* isolate, normalizeBridge("/ns/core"); } + // + // This block here is the runtime's + // defense-in-depth layer: even if the server (or any future tooling) + // emits a versioned or boot-tagged URL, the cache identity collapses to + // the canonical `/ns/m/` shape so V8 deduplicates correctly. + // + // The prefixes are stripped in fixed order — boot first (it's a static + // outermost wrapper), then hmr (one path segment whose tag may be + // `v`, `n`, `live`, or any alphanumeric value emitted by + // `formatNsMHmrServeTag`). The strip is idempotent: applying it twice + // yields the same result as applying it once. + { + std::string pathOnly = originAndPath.substr(pathStart); + bool changed = false; + + static constexpr const char kBootPrefix[] = "/ns/m/__ns_boot__/b1/"; + static constexpr size_t kBootPrefixLen = sizeof(kBootPrefix) - 1; + if (StartsWith(pathOnly, kBootPrefix)) { + pathOnly = std::string("/ns/m/") + pathOnly.substr(kBootPrefixLen); + changed = true; + } + + static constexpr const char kHmrPrefix[] = "/ns/m/__ns_hmr__/"; + static constexpr size_t kHmrPrefixLen = sizeof(kHmrPrefix) - 1; + if (StartsWith(pathOnly, kHmrPrefix)) { + size_t tagEnd = pathOnly.find('/', kHmrPrefixLen); + if (tagEnd != std::string::npos && tagEnd > kHmrPrefixLen) { + pathOnly = std::string("/ns/m/") + pathOnly.substr(tagEnd + 1); + changed = true; + } + } + + if (changed) { + originAndPath = originAndPath.substr(0, pathStart) + pathOnly; + } + } + + // IMPORTANT: This function is used as an HTTP module registry/cache key. + // For general-purpose HTTP module loading (public internet), the query string + // can be part of the module's identity (auth, content versioning, routing, etc). + // Therefore we only apply query normalization (sorting/dropping) for known + // NativeScript dev endpoints where `t`/`v`/`import` are purely cache busters. + // + // Special cases that LOOK like dev endpoints but aren't normalized: + // + // `/@ng/component` (Angular HMR component-update endpoint) + // The `t` (timestamp) parameter is the WHOLE POINT of the URL — it + // identifies a specific recompile of the component's metadata after + // a `.html`/style edit. Stripping it would collapse every HMR fetch + // to the same cache key (the boot-time call uses `Date.now()` and + // each subsequent save uses a new `Date.now()`), and the second + // `__ns_import(...)` would hit V8's module cache, resolve the + // boot-time `_UpdateMetadata` default export, and call + // `ɵɵreplaceMetadata` with stale instructions. Result: server logs + // `(client) hmr update`, the listener fires, but the visual never + // changes because the runtime swapped the live view's metadata + // with the same metadata it already had. Treat the path as a + // non-dev endpoint and preserve the query verbatim so each + // timestamped fetch is a distinct registry entry. + // + // Apply the special-case check BEFORE the dev-endpoint short-circuit so + // it covers paths under `/ns/m//@ng/component` (the + // resolved URL Angular's compiler produces relative to the component's + // `import.meta.url`). + { + std::string pathOnly = originAndPath.substr(pathStart); + if (pathOnly.find("/@ng/component") != std::string::npos) { + // Preserve query as-is — `t` is the version discriminator. + return noHash; + } + const bool isDevEndpoint = + StartsWith(pathOnly, "/ns/") || + StartsWith(pathOnly, "/node_modules/.vite/") || + StartsWith(pathOnly, "/@id/") || + StartsWith(pathOnly, "/@fs/"); + if (!isDevEndpoint) { + // Preserve query as-is (fragment already removed). + return noHash; + } + } + if (query.empty()) return originAndPath; - // Keep all params except Vite's import marker; sort for stability. + // Keep all params except typical import markers or t/v cache busters; sort for stability. std::vector kept; size_t start = 0; while (start <= query.size()) { @@ -224,7 +1424,8 @@ void InitializeImportMetaHot(v8::Isolate* isolate, if (!pair.empty()) { size_t eq = pair.find('='); std::string name = (eq == std::string::npos) ? pair : pair.substr(0, eq); - if (!(name == "import")) kept.push_back(pair); + // Drop import marker and common cache-busting stamps. + if (!(name == "import" || name == "t" || name == "v")) kept.push_back(pair); } if (amp == std::string::npos) break; start = amp + 1; @@ -239,6 +1440,92 @@ void InitializeImportMetaHot(v8::Isolate* isolate, return rebuilt; } +// ============================================================================ +// Speculative module-source prefetcher +// ============================================================================ +// +// V8 10.3.22 only exposes a synchronous ResolveModuleCallback for static +// imports. Each call into HttpFetchText() blocks the JS thread on a +// semaphore until that one HTTP response arrives, which forces serial +// fetching from the JS thread's perspective. Server-side telemetry +// shows this as `maxConcurrent=1` for the entire cold boot. +// +// This block speculatively prefetches a module's static imports the +// instant the parent's body arrives, before V8 has even started +// compiling the parent. Prefetches run on a concurrent GCD queue capped +// at kPrefetchMaxConcurrent and write into a thread-safe in-memory +// cache keyed by full URL. By the time V8 calls ResolveModuleCallback +// for a sibling, the source is already in cache and HttpFetchText +// returns instantly without touching the network. Effective parallelism +// goes from 1 → ~K where K = kPrefetchMaxConcurrent. +// +// Correctness invariants: +// 1. Cache reads consume (one-shot). A second HttpFetchText for the +// same URL after a cache hit triggers a fresh network fetch — this +// is the right behavior for HMR where re-fetching means we got a +// newer version of the module. +// 2. Every prefetch goes through IsRemoteUrlAllowed() exactly the +// same way HttpFetchText does. The security gate is preserved. +// 3. The scanner is best-effort. False positives just trigger one +// extra HTTP fetch the device might not need. False negatives just +// cost us K=1 for that one module — same as before this change. +// 4. Recursion happens via dispatch_async; the C++ stack never grows. + +static constexpr int kPrefetchMaxConcurrent = 4; +static constexpr size_t kPrefetchMaxImportsPerModule = 256; +static constexpr size_t kPrefetchSummaryEvery = 100; +static constexpr size_t kPrefetchMaxScanBytes = 256 * 1024; // skip very large bodies + +// Forward declarations — these helpers are defined below their first use, +// matching the existing convention in this file. +static bool PerformHttpFetchOnceSync(const std::string& url, std::string& out, std::string& contentType, int& status); +static std::vector ScanStaticImportSpecifiers(const std::string& source, size_t maxResults); +static std::string ResolveImportSpecifierAgainstUrl(const std::string& specifier, const std::string& parentUrl); +static bool LooksLikeJsSourceUrl(const std::string& url); +static void SchedulePrefetchForDeps(const std::string& parentUrl, const std::string& source); +static void SchedulePrefetchForDepsAsync(const std::string& parentUrl, const std::string& source); +static bool TryGetPrefetchedSource(const std::string& url, std::string& out); +static void MaybeLogPrefetchSummary(const char* trigger); +static void MaybePumpJSThreadDuringBoot(); +// Forward decl: the pluggable HTTP-fetch yield hook is defined below +// MaybePumpJSThreadDuringBoot (which is its default callback), but HttpFetchText +// calls it from earlier in the file. See the definition for the rationale on +// the atomic indirection. +static inline void InvokeHttpFetchYield(); + +static std::mutex g_prefetchMutex; +static std::unordered_map g_prefetchCache; +static std::unordered_set g_prefetchInflight; +static dispatch_queue_t g_prefetchQueue = dispatch_queue_create("com.nativescript.module.prefetch", DISPATCH_QUEUE_CONCURRENT); +static dispatch_semaphore_t g_prefetchConcurrencyLimit = dispatch_semaphore_create(kPrefetchMaxConcurrent); + +// Always-on diagnostic counters. These intentionally do NOT gate behind +// IsScriptLoadingLogEnabled() — without this signal we cannot tell a +// helping prefetcher from a hurting one. +static std::atomic g_prefetchHits{0}; // V8 asked for a URL we had cached +static std::atomic g_prefetchMisses{0}; // V8 asked for a URL we did not have +static std::atomic g_prefetchScheduled{0}; // background fetches we kicked off +static std::atomic g_prefetchSatisfied{0}; // background fetches that landed bytes in the cache +static std::atomic g_prefetchFailed{0}; // background fetches that returned non-2xx or empty +static std::atomic g_prefetchSkipped{0}; // candidates rejected (already cached/inflight, bare specifier, non-JS, blocked) + +// synchronous-fetch timing histogram. +// +// The histogram is intentionally coarse — +// just three buckets — and we log a summary once per kFetchSyncSummaryEvery +// completions. That keeps the noise low (one line per ~100 fetches) while +// still surfacing tail behavior. The "fast" bucket means a request landed +// in <10ms (typical for a kept-alive HTTP/1.1 connection on loopback); +// "slow" means >100ms (which usually means a fresh TCP/TLS handshake or +// a large response body). If most fetches are "fast", keep-alive is +// working. If most are "slow", we still have churn to track down. +static std::atomic g_fetchSyncCount{0}; +static std::atomic g_fetchSyncTotalMs{0}; +static std::atomic g_fetchSyncFast{0}; // <10ms +static std::atomic g_fetchSyncMedium{0}; // 10–99ms +static std::atomic g_fetchSyncSlow{0}; // >=100ms +static constexpr size_t kFetchSyncSummaryEvery = 100; + bool HttpFetchText(const std::string& url, std::string& out, std::string& contentType, int& status) { // Security gate: check if remote module loading is allowed before any HTTP fetch. // This is the single point of enforcement for all HTTP module loading. @@ -249,77 +1536,1695 @@ bool HttpFetchText(const std::string& url, std::string& out, std::string& conten } return false; } - - @autoreleasepool { - NSURL* u = [NSURL URLWithString:[NSString stringWithUTF8String:url.c_str()]]; + + const bool prefetchEnabled = IsHttpModulePrefetchEnabled(); + // Hoist the URL-log flag once per call so the two success branches + // below pay one TLS read instead of two. + const bool urlLogEnabled = IsHttpFetchUrlLogEnabled(); + + // the prefetch CACHE READ is always-on, + // independent of `httpModulePrefetch`. HMR client kicks + // off a synchronous BFS prefetch (`KickstartHmrPrefetchSync`) right + // before re-evaluating the entry module; that path populates + // `g_prefetchCache` regardless of whether speculative cold-boot + // prefetching is enabled. Gating the read here on `prefetchEnabled` + // would discard those bodies and force V8 back to the network on + // every save — defeating the entire purpose of kickstart. + // + // Speculative WRITES (`SchedulePrefetchForDepsAsync`) remain gated + // on the flag below, so cold-boot behaviour is unchanged for users + // who have not opted into `httpModulePrefetch: true`. + // + // Cache reads are one-shot; consuming the entry guarantees that a + // re-fetch (e.g. after HMR) goes back to the network for fresh source. + if (TryGetPrefetchedSource(url, out)) { + contentType = "application/javascript"; // best effort — same as the dev server returns + status = 200; + g_prefetchHits.fetch_add(1, std::memory_order_relaxed); + if (IsScriptLoadingLogEnabled()) { + Log(@"[http-loader][prefetch][hit] %s (%lu bytes)", url.c_str(), (unsigned long)out.size()); + } + if (urlLogEnabled) { + // Per-URL diagnostic. Distinguish prefetch-cache hits from + // network fetches so we can attribute who actually paid for + // each module body. ms is omitted because the cache lookup is + // effectively instantaneous compared to network I/O. + Log(@"[http-loader][fetch][prefetch] %s bytes=%lu", + url.c_str(), (unsigned long)out.size()); + } + MaybeLogPrefetchSummary("hit"); + // Chain the wave: scan the cached body for its own imports and + // schedule those prefetches off the JS thread. The scan itself is + // CPU work; running it inline on every cache hit was burning the + // very thread we are trying to unblock. + SchedulePrefetchForDepsAsync(url, out); + // Yield to the placeholder heartbeat between cache hits — without + // this the runloop is starved by back-to-back HttpFetchText calls. + InvokeHttpFetchYield(); + return true; + } + + // Slow path: cache miss → synchronous fetch with one retry on failure. + // This preserves the original HttpFetchText behavior exactly. + if (prefetchEnabled) { + g_prefetchMisses.fetch_add(1, std::memory_order_relaxed); + } + // Time the network branch end-to-end so the per-URL log can + // attribute milliseconds to each fetch. We measure here (not + // inside PerformHttpFetchOnceSync) so the retry interval gets + // billed to the URL too — which is what the user sees as "this + // URL was slow". + const uint64_t netStartUs = urlLogEnabled + ? (uint64_t)(CFAbsoluteTimeGetCurrent() * 1000.0 * 1000.0) + : 0ull; + bool ok = PerformHttpFetchOnceSync(url, out, contentType, status); + if (!ok) { + if (IsScriptLoadingLogEnabled()) { + Log(@"[http-loader] retrying %s after initial fetch error", url.c_str()); + } + usleep(120 * 1000); + ok = PerformHttpFetchOnceSync(url, out, contentType, status); + } + if (!ok || status < 200 || status >= 300) { + return false; + } + if (out.empty()) return false; + if (IsScriptLoadingLogEnabled()) { + unsigned long long blen = (unsigned long long)out.size(); + const char* ctstr = contentType.empty() ? "" : contentType.c_str(); + Log(@"[http-loader] fetched status=%d content-type=%s bytes=%llu", status, ctstr, blen); + } + if (urlLogEnabled) { + const uint64_t netEndUs = (uint64_t)(CFAbsoluteTimeGetCurrent() * 1000.0 * 1000.0); + const uint64_t netMs = netEndUs > netStartUs ? (netEndUs - netStartUs) / 1000ull : 0ull; + Log(@"[http-loader][fetch][network] %s bytes=%lu ms=%llu", + url.c_str(), (unsigned long)out.size(), (unsigned long long)netMs); + } + + // Speculative prefetch: kick off async fetches for this module's + // static imports. By the time V8 walks the dep tree on the JS thread, + // those bodies are already in g_prefetchCache. + if (prefetchEnabled) { + SchedulePrefetchForDepsAsync(url, out); + } + MaybeLogPrefetchSummary("miss"); + // Yield to the placeholder heartbeat after the 10–60ms sync fetch + // block so the bar can repaint before V8 calls us again. + InvokeHttpFetchYield(); + return true; +} + +// Synchronous HTTP fetcher implementation. +// +// We use `+[NSURLConnection sendSynchronousRequest:returningResponse:error:]` +// (deprecated but functional on every shipping iOS version) instead of +// the modern NSURLSession API. NSURLSession exhibits a deadlock when the +// JS thread is the iOS main thread (post-Angular bootstrap): +// +// - JS calls `import('foo')` (dynamic import). +// - The runtime sync-fetches `foo`'s body on the main thread, blocking +// on `dispatch_semaphore_wait`. This first fetch lands normally +// (e.g. `hmr/client/index.js` arrives in ~60ms). +// - V8 then synchronously calls `InstantiateModule`, which invokes our +// `ResolveModuleCallback` for each static dependency. That callback +// issues another sync fetch (e.g. `hmr/client/utils.js`). +// - For this second sync fetch, NSURLSessionDataTask transitions to +// NSURLSessionTaskStateRunning, but the completion handler **never +// fires** within 6 seconds. NSURLSession's own +// `timeoutIntervalForRequest` does not trip either — `task.error` +// stays nil. The task remains stuck in Running state. Cancelling +// it synchronously does not produce a completion-handler callback. +// +// The deadlock reproduces with both an implicit delegate queue and an +// explicit non-main `NSOperationQueue`. It also reproduces with +// `httpModulePrefetch` disabled, ruling out prefetcher contention. +// Boot-time sync fetches (thousands of them) succeed because they happen +// before the iOS main thread becomes the JS executor. +// +// `NSURLConnection.sendSynchronousRequest` uses CFNetwork directly, +// bypassing NSURLSession's task lifecycle, and returns the NSURLResponse +// so we can read HTTP status and Content-Type. The deprecation warning +// is suppressed locally because every published Apple SDK still ships +// a working implementation, and there is currently no non-deprecated +// API that gives us a runloop-independent synchronous fetch with a +// real HTTP status code. +static bool PerformHttpFetchOnceSync(const std::string& url, std::string& out, std::string& contentType, int& status) { + @autoreleasepool { + // One-time: replace the shared NSURLCache with a zero-capacity one + // so CFNetwork has no on-disk store to satisfy fetches from. Per- + // request cache policy + `removeCachedResponseForRequest:` were + // empirically insufficient on iOS 18+/26+ Simulator — fsCachedData + // would still serve a previous save's body for a tagged HMR URL. + static dispatch_once_t s_cacheDisableOnce; + dispatch_once(&s_cacheDisableOnce, ^{ + NSURLCache* nullCache = [[NSURLCache alloc] initWithMemoryCapacity:0 + diskCapacity:0 + directoryURL:nil]; + [NSURLCache setSharedURLCache:nullCache]; + }); + + // For HMR re-fetch URLs (`/ns/m/__ns_hmr__//...`), append a + // unique nonce query parameter so CFNetwork sees a different URL + // every time and cannot satisfy from any cache. Vite ignores + // unknown query params on these routes, so the response body is + // unchanged. Scoped to HMR URLs only because some Vite virtual + // routes (e.g. `/@nativescript/vendor.mjs`) require exact-match + // URLs and 404 on unknown query params. Boot fetches don't need + // cache busting — first-touch by definition. + std::string fetchUrl = url; + if (url.find("__ns_hmr__") != std::string::npos) { + static std::atomic s_fetchSeq{0}; + const uint64_t seq = s_fetchSeq.fetch_add(1, std::memory_order_relaxed); + const uint64_t nowMs = (uint64_t)(CFAbsoluteTimeGetCurrent() * 1000.0); + fetchUrl += (url.find('?') == std::string::npos) ? '?' : '&'; + fetchUrl += "__ns_dev_nonce="; + fetchUrl += std::to_string(nowMs); + fetchUrl += "-"; + fetchUrl += std::to_string(seq); + } + + NSURL* u = [NSURL URLWithString:[NSString stringWithUTF8String:fetchUrl.c_str()]]; if (!u) { status = 0; return false; } - __block NSError* err = nil; - __block NSInteger httpStatusLocal = 0; - __block std::string contentTypeLocal; - __block std::string bodyLocal; - - auto fetchOnce = ^BOOL(NSURL* reqUrl) { - bodyLocal.clear(); - err = nil; - httpStatusLocal = 0; - contentTypeLocal.clear(); - NSURLSessionConfiguration* cfg = [NSURLSessionConfiguration defaultSessionConfiguration]; - cfg.HTTPAdditionalHeaders = @{ @"Accept": @"application/javascript, text/javascript, */*;q=0.1", - @"Accept-Encoding": @"identity" }; - // Note: this could be made configurable if needed - cfg.timeoutIntervalForRequest = 5.0; - cfg.timeoutIntervalForResource = 5.0; - NSURLSession* session = [NSURLSession sessionWithConfiguration:cfg]; - dispatch_semaphore_t sema = dispatch_semaphore_create(0); - NSURLSessionDataTask* task = [session dataTaskWithURL:reqUrl - completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { - @autoreleasepool { - err = error; - if ([response isKindOfClass:[NSHTTPURLResponse class]]) { - httpStatusLocal = ((NSHTTPURLResponse*)response).statusCode; - NSString* ct = ((NSHTTPURLResponse*)response).allHeaderFields[@"Content-Type"]; - if (ct) { contentTypeLocal = std::string([ct UTF8String] ?: ""); } - } - if (data) { - const void* bytes = [data bytes]; - NSUInteger len = [data length]; - if (bytes && len > 0) { - bodyLocal.assign(static_cast(bytes), static_cast(len)); - } - } - } - dispatch_semaphore_signal(sema); - }]; - [task resume]; - dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(6 * NSEC_PER_SEC)); - dispatch_semaphore_wait(sema, timeout); - [session finishTasksAndInvalidate]; - return err == nil && !bodyLocal.empty(); - }; + NSError* err = nil; + NSInteger httpStatusLocal = 0; + std::string contentTypeLocal; + std::string bodyLocal; - BOOL ok = fetchOnce(u); - if (!ok) { - if (tns::IsScriptLoadingLogEnabled()) { Log(@"[http-loader] retrying %s after initial fetch error", url.c_str()); } - usleep(120 * 1000); - ok = fetchOnce(u); + const auto fetchStartUs = (uint64_t)(CFAbsoluteTimeGetCurrent() * 1000.0 * 1000.0); + + NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:u]; + [request setHTTPMethod:@"GET"]; + [request setValue:@"application/javascript, text/javascript, */*;q=0.1" + forHTTPHeaderField:@"Accept"]; + [request setValue:@"identity" forHTTPHeaderField:@"Accept-Encoding"]; + [request setTimeoutInterval:5.0]; + // CRITICAL for HMR: layered defense to bypass CFNetwork's URL cache. + // `setCachePolicy:` alone is insufficient on iOS 18+/26+ Simulator — + // CFNetwork still serves a previous save's body for a tagged HMR + // URL from fsCachedData. Combined with the zero-capacity + // sharedURLCache and per-request URL nonce above, these give us a + // reliable "always go to origin" path for the dev runtime. + [request setValue:@"no-cache, no-store, max-age=0" + forHTTPHeaderField:@"Cache-Control"]; + [request setValue:@"no-cache" forHTTPHeaderField:@"Pragma"]; + // Force a fresh TCP connection per fetch. CFNetwork has been + // observed to serve a body buffered on a kept-alive HTTP/1.1 + // connection for a prior fetch when a new fetch reuses it. + [request setValue:@"close" forHTTPHeaderField:@"Connection"]; + [request setCachePolicy:NSURLRequestReloadIgnoringLocalAndRemoteCacheData]; + [request setHTTPShouldHandleCookies:NO]; + [request setHTTPShouldUsePipelining:NO]; + [[NSURLCache sharedURLCache] removeCachedResponseForRequest:request]; + + NSURLResponse* response = nil; +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + NSData* data = [NSURLConnection sendSynchronousRequest:request + returningResponse:&response + error:&err]; +#pragma clang diagnostic pop + + // Drop any response sendSynchronousRequest: implicitly stored so it + // cannot poison a later fetch of the same URL. + [[NSURLCache sharedURLCache] removeCachedResponseForRequest:request]; + + if ([response isKindOfClass:[NSHTTPURLResponse class]]) { + NSHTTPURLResponse* httpResp = (NSHTTPURLResponse*)response; + httpStatusLocal = [httpResp statusCode]; + NSString* ct = [httpResp allHeaderFields][@"Content-Type"]; + if (ct) { + const char* utf8 = [ct UTF8String]; + if (utf8) contentTypeLocal = std::string(utf8); + } + } + + if (data && [data length] > 0) { + const void* bytes = [data bytes]; + NSUInteger len = [data length]; + bodyLocal.assign(static_cast(bytes), static_cast(len)); + } + + const auto fetchEndUs = (uint64_t)(CFAbsoluteTimeGetCurrent() * 1000.0 * 1000.0); + const uint64_t fetchMs = fetchEndUs > fetchStartUs ? (fetchEndUs - fetchStartUs) / 1000ull : 0ull; + g_fetchSyncTotalMs.fetch_add(fetchMs, std::memory_order_relaxed); + if (fetchMs < 10) { + g_fetchSyncFast.fetch_add(1, std::memory_order_relaxed); + } else if (fetchMs < 100) { + g_fetchSyncMedium.fetch_add(1, std::memory_order_relaxed); + } else { + g_fetchSyncSlow.fetch_add(1, std::memory_order_relaxed); + } + const size_t syncCount = g_fetchSyncCount.fetch_add(1, std::memory_order_relaxed) + 1; + if (syncCount > 0 && syncCount % kFetchSyncSummaryEvery == 0 && + IsScriptLoadingLogEnabled()) { + const size_t fast = g_fetchSyncFast.load(std::memory_order_relaxed); + const size_t medium = g_fetchSyncMedium.load(std::memory_order_relaxed); + const size_t slow = g_fetchSyncSlow.load(std::memory_order_relaxed); + const uint64_t totalMs = g_fetchSyncTotalMs.load(std::memory_order_relaxed); + const uint64_t avgMs = syncCount ? totalMs / (uint64_t)syncCount : 0; + Log(@"[http-loader][fetch-sync][summary] count=%lu avg=%llums fast(<10ms)=%lu medium=%lu slow(>=100ms)=%lu", + (unsigned long)syncCount, + (unsigned long long)avgMs, + (unsigned long)fast, + (unsigned long)medium, + (unsigned long)slow); } status = (int)httpStatusLocal; contentType = contentTypeLocal; - if (!ok || status < 200 || status >= 300) { + if (err != nil || bodyLocal.empty()) { + if (IsScriptLoadingLogEnabled()) { + NSString* desc = err.localizedDescription ?: @""; + NSString* domain = err.domain ?: @""; + Log(@"[http-loader][fetch-error] url=%s domain=%@ code=%ld desc=%@ status=%ld bodyEmpty=%d ms=%llu", + url.c_str(), + domain, + (long)err.code, + desc, + (long)httpStatusLocal, + bodyLocal.empty() ? 1 : 0, + (unsigned long long)fetchMs); + } return false; } - out.swap(bodyLocal); - if (out.empty()) return false; - if (tns::IsScriptLoadingLogEnabled()) { - unsigned long long blen = (unsigned long long)out.size(); - const char* ctstr = contentType.empty() ? "" : contentType.c_str(); - Log(@"[http-loader] fetched status=%ld content-type=%s bytes=%llu", (long)status, ctstr, blen); + return true; + } +} + +static bool TryGetPrefetchedSource(const std::string& url, std::string& out) { + std::lock_guard lock(g_prefetchMutex); + auto it = g_prefetchCache.find(url); + if (it == g_prefetchCache.end()) return false; + out = std::move(it->second); + g_prefetchCache.erase(it); + return true; +} + +// Drop a specific URL set from `g_prefetchCache`. Used by +// `InvalidateModules` so an HMR eviction purges any stale HTTP body +// the previous prefetch wave left behind. See the doc comment in +// HMRSupport.h for the cache-poisoning case this fixes. +void EvictHttpModulePrefetchCacheUrls(const std::vector& urls) { + if (urls.empty()) return; + size_t dropped = 0; + { + std::lock_guard lock(g_prefetchMutex); + for (const auto& url : urls) { + if (url.empty()) continue; + auto it = g_prefetchCache.find(url); + if (it != g_prefetchCache.end()) { + g_prefetchCache.erase(it); + ++dropped; + } + } + } + if (dropped > 0 && IsScriptLoadingLogEnabled()) { + Log(@"[http-loader][prefetch][evict] dropped=%lu of %lu", + (unsigned long)dropped, (unsigned long)urls.size()); + } +} + +static bool IsIdentifierChar(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '$'; +} + +static bool IsHorizontalWs(char c) { return c == ' ' || c == '\t'; } + +// Walks back over horizontal whitespace and returns the previous +// non-whitespace character, or 0 if we reached the start of the file. +static char PreviousNonHwsChar(const std::string& source, size_t hit) { + if (hit == 0) return 0; + ssize_t i = (ssize_t)hit - 1; + while (i >= 0 && IsHorizontalWs(source[i])) i--; + if (i < 0) return 0; + return source[i]; +} + +// Tighter import scanner +// +// What we accept: +// `} from "..."` named-import block +// `* from "..."` wildcard re-export +// ` from "..."` default import / `as Foo from` +// ` import "..."` side-effect import +// ` export ... from "..."` (caught by the `from` rule) +// +// What we explicitly reject: +// `.from("...")` member access (Array.from, etc.) +// `.import("...")` member access on dynamic-import-shaped APIs +// `import("...")` dynamic imports — they almost never run +// at boot for Angular and the speculative +// wave on lazy chunks blew the budget. +// matches inside template / string literals where the previous non-WS +// char is a quote character (best-effort guard). +// +// False positives still possible inside multi-line string literals or +// comments containing the literal token sequences above; those are +// rare in real code and just cost one redundant HTTP fetch. +static std::vector ScanStaticImportSpecifiers(const std::string& source, size_t maxResults) { + std::vector result; + if (source.size() > kPrefetchMaxScanBytes) { + return result; // skip very large bodies; we'd have nothing useful to prefetch anyway + } + std::unordered_set seen; + result.reserve(16); + + auto captureSpecAfter = [&](size_t cursor) -> ssize_t { + // Skip whitespace before the quote. + 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 (ssize_t)(end + 1); + }; + + // ── Pass 1: `from "..."` ────────────────────────────────────────── + // Accept only when the char immediately preceding `from` (after + // optional horizontal whitespace) is `}`, `*`, or an identifier + // character. Reject `.from(...)`. + { + 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(source[hit - 1])) { pos = hit + 1; continue; } + size_t after = hit + needleLen; + if (after < source.size() && IsIdentifierChar(source[after])) { pos = hit + 1; continue; } + char prev = PreviousNonHwsChar(source, hit); + // Accept import-context predecessors only. + bool ok = (prev == '}' || prev == '*' || prev == ',' || IsIdentifierChar(prev)); + if (!ok) { pos = hit + 1; continue; } + ssize_t adv = captureSpecAfter(after); + if (adv < 0) { pos = hit + 1; continue; } + pos = (size_t)adv; + } + } + + // ── Pass 2: side-effect `import "..."` ──────────────────────────── + // Accept only when `import` is at the start of a statement: the + // previous non-horizontal-whitespace character must be a newline, + // `;`, `}`, or 0 (start of file). Reject member access (`.import`) + // and dynamic imports (`import(...)`) — both cause more harm than + // good for the cold-boot wave. + { + 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(source[hit - 1])) { pos = hit + 1; continue; } + size_t after = hit + needleLen; + if (after < source.size() && IsIdentifierChar(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; } + // Distinguish `import "..."` (static) from `import(...)` and + // `import X from "..."` (handled by Pass 1). + // After `import`, skip horizontal whitespace and look at the + // first non-whitespace character. + 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; } // dynamic — skip + if (next != '"' && next != '\'' && next != '`') { pos = hit + 1; continue; } // `import X from` — Pass 1 handles + ssize_t adv = captureSpecAfter(cursor); + if (adv < 0) { pos = hit + 1; continue; } + pos = (size_t)adv; + } + } + + return result; +} + +static std::string ResolveImportSpecifierAgainstUrl(const std::string& specifier, const std::string& parentUrl) { + if (specifier.empty()) return ""; + + // Already absolute. + if (StartsWith(specifier, "http://") || StartsWith(specifier, "https://")) { + return specifier; + } + + // Skip bare specifiers (need an import map we don't replicate here). + bool isRelative = StartsWith(specifier, "./") || StartsWith(specifier, "../"); + bool isRootAbs = !specifier.empty() && specifier[0] == '/'; + if (!isRelative && !isRootAbs) return ""; + + @autoreleasepool { + NSString* parent = [NSString stringWithUTF8String:parentUrl.c_str()]; + NSString* spec = [NSString stringWithUTF8String:specifier.c_str()]; + if (!parent || !spec) return ""; + NSURL* baseUrl = [NSURL URLWithString:parent]; + if (!baseUrl) return ""; + NSURL* resolved = [NSURL URLWithString:spec relativeToURL:baseUrl]; + if (!resolved) return ""; + NSURL* abs = [resolved absoluteURL]; + NSString* result = abs ? [abs absoluteString] : nil; + if (!result) return ""; + const char* utf8 = [result UTF8String]; + return utf8 ? std::string(utf8) : std::string(); + } +} + +static bool LooksLikeJsSourceUrl(const std::string& url) { + // Strip query string for extension check. + size_t qpos = url.find('?'); + std::string path = (qpos == std::string::npos) ? url : url.substr(0, qpos); + + // Skip non-JS resource types that V8 either won't request through this + // path or that would break our content-type assumption on cache hit. + if (EndsWith(path, ".css") || EndsWith(path, ".scss") || EndsWith(path, ".sass") || EndsWith(path, ".less")) return false; + if (EndsWith(path, ".png") || EndsWith(path, ".jpg") || EndsWith(path, ".jpeg") || EndsWith(path, ".gif") || EndsWith(path, ".svg") || EndsWith(path, ".webp") || EndsWith(path, ".ico")) return false; + if (EndsWith(path, ".json")) return false; + if (EndsWith(path, ".html") || EndsWith(path, ".htm")) return false; + if (EndsWith(path, ".woff") || EndsWith(path, ".woff2") || EndsWith(path, ".ttf") || EndsWith(path, ".otf") || EndsWith(path, ".eot")) return false; + if (EndsWith(path, ".mp4") || EndsWith(path, ".webm") || EndsWith(path, ".mp3") || EndsWith(path, ".wav")) return false; + return true; +} + +static void SchedulePrefetchForDeps(const std::string& parentUrl, const std::string& source) { + std::vector specifiers = ScanStaticImportSpecifiers(source, kPrefetchMaxImportsPerModule); + if (specifiers.empty()) return; + + std::vector toFetch; + toFetch.reserve(specifiers.size()); + + for (const std::string& spec : specifiers) { + std::string absUrl = ResolveImportSpecifierAgainstUrl(spec, parentUrl); + if (absUrl.empty()) { + g_prefetchSkipped.fetch_add(1, std::memory_order_relaxed); + continue; + } + if (!StartsWith(absUrl, "http://") && !StartsWith(absUrl, "https://")) { + g_prefetchSkipped.fetch_add(1, std::memory_order_relaxed); + continue; } + if (!LooksLikeJsSourceUrl(absUrl)) { + g_prefetchSkipped.fetch_add(1, std::memory_order_relaxed); + continue; + } + if (!IsRemoteUrlAllowed(absUrl)) { + g_prefetchSkipped.fetch_add(1, std::memory_order_relaxed); + continue; + } + + std::lock_guard lock(g_prefetchMutex); + if (g_prefetchCache.find(absUrl) != g_prefetchCache.end()) { + g_prefetchSkipped.fetch_add(1, std::memory_order_relaxed); + continue; + } + if (!g_prefetchInflight.insert(absUrl).second) { + g_prefetchSkipped.fetch_add(1, std::memory_order_relaxed); + continue; + } + toFetch.push_back(absUrl); + } + + if (toFetch.empty()) return; + + for (const std::string& url : toFetch) { + g_prefetchScheduled.fetch_add(1, std::memory_order_relaxed); + std::string urlCopy = url; + dispatch_async(g_prefetchQueue, ^{ + // Concurrency gate — never more than kPrefetchMaxConcurrent + // simultaneous network fetches in flight from the prefetcher. + dispatch_semaphore_wait(g_prefetchConcurrencyLimit, DISPATCH_TIME_FOREVER); + + std::string body; + std::string contentType; + int status = 0; + bool ok = PerformHttpFetchOnceSync(urlCopy, body, contentType, status); + + if (ok && status >= 200 && status < 300 && !body.empty()) { + { + std::lock_guard lock(g_prefetchMutex); + g_prefetchCache[urlCopy] = body; + g_prefetchInflight.erase(urlCopy); + } + g_prefetchSatisfied.fetch_add(1, std::memory_order_relaxed); + if (IsScriptLoadingLogEnabled()) { + Log(@"[http-loader][prefetch] cached %s (%lu bytes)", urlCopy.c_str(), (unsigned long)body.size()); + } + // Recursively prefetch this module's deps. Recursion is via + // dispatch_async, so the C++ stack never grows; depth is + // implicitly bounded by the dep graph plus the dedupe set. + SchedulePrefetchForDeps(urlCopy, body); + } else { + g_prefetchFailed.fetch_add(1, std::memory_order_relaxed); + std::lock_guard lock(g_prefetchMutex); + g_prefetchInflight.erase(urlCopy); + } + + dispatch_semaphore_signal(g_prefetchConcurrencyLimit); + }); + } +} + +// Schedule prefetch on a background thread. The actual scan + URL +// resolution is the part we want OFF the JS thread — that is where we +// were burning cycles on every cache hit. Capturing source by value +// costs one std::string copy (small); we pay it once per HttpFetchText +// success and recover much more time on the JS thread. +static void SchedulePrefetchForDepsAsync(const std::string& parentUrl, const std::string& source) { + if (source.empty()) return; + std::string urlCopy = parentUrl; + std::string sourceCopy = source; + dispatch_async(g_prefetchQueue, ^{ + SchedulePrefetchForDeps(urlCopy, sourceCopy); + }); +} + +// Periodic summary of prefetcher counters. Logs once every +// kPrefetchSummaryEvery hits+misses+satisfied+failed events, plus +// on the trailing edge of cache cleanup. Gated on the logScriptLoading +// flag so it stays silent by default — flip the flag when diagnosing +// prefetch behavior. +static void MaybeLogPrefetchSummary(const char* trigger) { + size_t hits = g_prefetchHits.load(std::memory_order_relaxed); + size_t misses = g_prefetchMisses.load(std::memory_order_relaxed); + size_t scheduled = g_prefetchScheduled.load(std::memory_order_relaxed); + size_t satisfied = g_prefetchSatisfied.load(std::memory_order_relaxed); + size_t failed = g_prefetchFailed.load(std::memory_order_relaxed); + size_t skipped = g_prefetchSkipped.load(std::memory_order_relaxed); + size_t total = hits + misses; + if (total == 0) return; + if (total % kPrefetchSummaryEvery != 0) return; + if (!IsScriptLoadingLogEnabled()) return; + + size_t cacheSize = 0; + size_t inflight = 0; + { + std::lock_guard lock(g_prefetchMutex); + cacheSize = g_prefetchCache.size(); + inflight = g_prefetchInflight.size(); + } + + size_t hitPct = total ? (hits * 100 / total) : 0; + Log(@"[http-loader][prefetch][summary] trigger=%s totalAsks=%lu hits=%lu (%lu%%) misses=%lu scheduled=%lu satisfied=%lu failed=%lu skipped=%lu cache=%lu inflight=%lu", + trigger, + (unsigned long)total, + (unsigned long)hits, (unsigned long)hitPct, + (unsigned long)misses, + (unsigned long)scheduled, + (unsigned long)satisfied, + (unsigned long)failed, + (unsigned long)skipped, + (unsigned long)cacheSize, + (unsigned long)inflight); +} + +// Cold-boot JS-thread runloop pump. +// +// Synchronous `HttpFetchText` calls during V8's static-import walk park +// the JS thread inside `+sendSynchronousRequest:`, starving the +// `setInterval` heartbeat that drives the placeholder progress bar. +// Between fetches we run one short CFRunLoop slice in default mode so +// any due `CFRunLoopTimer` (the heartbeat) fires once before we return. +// Microtask checkpoints bracket the slice to flush V8 promise queues +// either side of the timer callback. v8::Locker is recursive, so nested +// acquisition by the timer callback is safe. +// +// Gated to JS-thread + cold-boot only: +// - `Runtime::GetCurrentRuntime()` is thread_local; null on GCD +// prefetch threads, so they never pump someone else's runloop. +// - `IsDevSessionBootComplete()` short-circuits once Angular has +// committed its first stable view — no placeholder to repaint, and +// HMR-time fetches must not pay the pump cost. +// - The runloop identity check survives any future change that +// decouples the runtime's captured runloop from the current thread. +static void MaybePumpJSThreadDuringBoot() { + Runtime* runtime = Runtime::GetCurrentRuntime(); + if (runtime == nullptr) return; + if (IsDevSessionBootComplete()) return; + + v8::Isolate* isolate = runtime->GetIsolate(); + if (isolate == nullptr) return; + + CFRunLoopRef rl = runtime->RuntimeLoop(); + if (rl == nullptr || rl != CFRunLoopGetCurrent()) return; + + isolate->PerformMicrotaskCheckpoint(); + @autoreleasepool { + // 1ms slice: long enough to cover the placeholder's 250ms-cadence + // heartbeat when overdue, short enough that ~200 boot fetches add + // <200ms of pump overhead total. + NSRunLoop* runLoop = [NSRunLoop currentRunLoop]; + NSDate* sliceDeadline = [NSDate dateWithTimeIntervalSinceNow:0.001]; + [runLoop runMode:NSDefaultRunLoopMode beforeDate:sliceDeadline]; + } + isolate->PerformMicrotaskCheckpoint(); +} + +// Pluggable "yield to caller" hook used by HttpFetchText. The default +// implementation pumps the JS thread runloop during dev-session cold boot +// (see MaybePumpJSThreadDuringBoot for the gating rationale). Hosts can +// override or null it out via RegisterHttpFetchYield to keep HTTP fetches +// fully synchronous without any UI concerns leaking in. +// +// NOTE: function-pointer atomics are guaranteed lock-free on iOS for +// pointer-sized targets, so this carries no extra lock cost on the hot +// path. Read uses memory_order_acquire so callers see the pointer +// installed via memory_order_release in `RegisterHttpFetchYield`. +static std::atomic g_httpFetchYield{&MaybePumpJSThreadDuringBoot}; + +void RegisterHttpFetchYield(void (*callback)()) { + g_httpFetchYield.store(callback, std::memory_order_release); +} + +static inline void InvokeHttpFetchYield() { + auto cb = g_httpFetchYield.load(std::memory_order_acquire); + if (cb != nullptr) cb(); +} + +void ClearHttpModulePrefetchCache() { + std::lock_guard lock(g_prefetchMutex); + g_prefetchCache.clear(); + g_prefetchInflight.clear(); +} + +// HMR-driven kickstart prefetch. +// +// `__ns_hmr__/v` URL prefixes are part of V8's cache key, so the +// dev server bumping `graphVersion` on each save makes every save look +// cold to V8. The kickstart pre-populates `g_prefetchCache` via a +// parallel BFS over `NSURLSession` (kept-alive) before V8 walks, so +// each `HttpFetchText` resolves from the cache (~microseconds) instead +// of the network (~10ms). +// +// `dispatch_group_wait` provides clean "BFS fully drained" semantics +// before V8 starts walking; the per-call queue isolates this group +// from other HMR cycles. We deliberately reuse `g_prefetchCache` +// (rather than a kickstart-only map) so the read path in +// `HttpFetchText` stays single-source — speculative-prefetch and +// kickstart consumers share the same destructive-read code. +namespace { + +struct KickstartContext { + std::mutex mutex; + std::unordered_set visited; + std::atomic fetchedCount{0}; + std::atomic bytes{0}; + dispatch_group_t group = nullptr; + dispatch_queue_t queue = nullptr; + dispatch_semaphore_t concurrency = nullptr; + // `recursive == true`: BFS scans each fetched body for static + // imports (cold-boot speculative prefetcher and the legacy + // single-seed kickstart). `recursive == false`: fetch only the + // explicit URLs given (HMR-driven kickstart, where the dev server + // already computed the inverse-dep closure in `evictPaths`). + bool recursive = true; + + // ARC-disabled file: dispatch_release is required. By the time the + // shared_ptr owning this context drops to zero, dispatch_group_wait + // has returned and every scheduled block has released its capture. + ~KickstartContext() { + if (group) dispatch_release(group); + if (queue) dispatch_release(queue); + if (concurrency) dispatch_release(concurrency); + } +}; + +} // anonymous namespace + +static 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, if a previous wave (or an + // opt-in speculative prefetch) already landed this body, treat + // the URL as covered — no point spinning up a fetch we'd discard + // anyway. + // + // In HMR (non-recursive) mode this guard is *toxic*: the caller + // has explicitly told us "these URLs are stale, please refetch", + // and any body sitting in `g_prefetchCache` is a leftover from + // the previous wave that V8 didn't consume. Honoring the cache + // here would feed V8 the stale body on the next walk — the + // "1 cycle behind" symptom for `.ts` edits with many transitive + // importers. So we skip this short-circuit entirely when + // `recursive == false`. The emplace-vs-overwrite decision below + // is also tightened for the same reason. (`InvalidateModules` + // now pre-clears the cache for the eviction set, so this is + // defense-in-depth — but the kickstart may also be invoked + // manually for diagnostics, and we want it to be correct in + // isolation.) + if (ctx->recursive) { + std::lock_guard lock(g_prefetchMutex); + if (g_prefetchCache.find(urlRef) != g_prefetchCache.end()) continue; + } + + dispatch_group_enter(ctx->group); + std::string urlCopy = urlRef; + const bool hmrMode = !ctx->recursive; + dispatch_async(ctx->queue, ^{ + dispatch_semaphore_wait(ctx->concurrency, DISPATCH_TIME_FOREVER); + + std::string body; + std::string contentType; + int status = 0; + bool ok = PerformHttpFetchOnceSync(urlCopy, body, contentType, status); + + if (ok && status >= 200 && status < 300 && !body.empty()) { + size_t bodySize = body.size(); + // Recursive (cold-boot) — Insert (do not overwrite). Another + // path may have already landed the same URL via the + // speculative prefetcher; honor whichever copy got there + // first to avoid wastefully clobbering an already-valid + // cache entry. + // + // HMR (non-recursive) — When the caller is the HMR + // kickstart, the *fresh* body we just fetched is by + // definition the authoritative copy; any older entry in the + // cache is stale by construction (the dev server has just + // told us so). So overwrite unconditionally for HMR. The + // recursive cold-boot path keeps its emplace semantics. + std::string scanSource; + { + std::lock_guard lock(g_prefetchMutex); + if (hmrMode) { + auto& slot = g_prefetchCache[urlCopy]; + slot = std::move(body); + scanSource = slot; + bodySize = slot.size(); + } else { + auto inserted = g_prefetchCache.emplace(urlCopy, std::move(body)); + if (inserted.second) { + scanSource = inserted.first->second; // take a copy for off-lock scanning + } else { + scanSource = inserted.first->second; + bodySize = inserted.first->second.size(); + } + } + } + ctx->fetchedCount.fetch_add(1, std::memory_order_relaxed); + ctx->bytes.fetch_add(bodySize, std::memory_order_relaxed); + + // Only walk the dep graph when the caller asked for BFS. + // HMR kickstart drives this with a precomputed inverse-dep + // closure (`evictPaths`) and sets recursive=false to skip a + // full graph re-scan that would only re-discover the set we + // already have. + if (ctx->recursive) { + // Recurse: scan the body for static imports, resolve each + // specifier against this URL, and schedule any new URLs. + 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(ctx, std::move(nextUrls)); + } + } + } + } + + dispatch_semaphore_signal(ctx->concurrency); + dispatch_group_leave(ctx->group); + }); + } +} + +// Internal multi-URL kickstart. Both the legacy single-seed +// `KickstartHmrPrefetchSync` and the HMR-driven +// `KickstartHmrPrefetchUrlsSync` funnel through here so the two +// callers share one validated, instrumented code path. +// +// `recursive=true` → seed-rooted BFS over static imports (cold boot, +// legacy callers). +// `recursive=false` → fetch the provided list and stop (HMR cycle: +// server already gave us the inverse-dep closure). +static 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; + // Drop empty / non-allowlisted URLs up front. We still want a + // truthy result even if some entries get filtered, because partial + // success is strictly better than the pre-kickstart baseline. + 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 uint64_t startUs = (uint64_t)(CFAbsoluteTimeGetCurrent() * 1000.0 * 1000.0); + + auto ctx = std::make_shared(); + ctx->group = dispatch_group_create(); + ctx->queue = dispatch_queue_create("com.nativescript.hmr.kickstart", DISPATCH_QUEUE_CONCURRENT); + ctx->concurrency = dispatch_semaphore_create(maxConcurrent); + ctx->recursive = recursive; + + KickstartScheduleUrls(ctx, std::move(filtered)); + + // Cold-boot caller (JS thread, pre-bootstrap): poll `dispatch_group_wait` + // in 50ms slices and pump the runloop between them so the placeholder + // heartbeat keeps ticking. HMR-refresh caller (post-bootstrap or + // off-thread): plain blocking wait — no bar to animate and the wait + // is short. Pump cost on a 21s cold-boot kickstart: ~600 syscalls + + // ~600ms of CFRunLoop slices, in exchange for ~85 heartbeat ticks. + long timedOut; + Runtime* coldBootRuntime = Runtime::GetCurrentRuntime(); + const bool useColdBootPumpWait = coldBootRuntime != nullptr && !IsDevSessionBootComplete(); + if (useColdBootPumpWait) { + const int64_t sliceNs = 50LL * NSEC_PER_MSEC; + const uint64_t timeoutUs = (uint64_t)(timeoutSeconds * 1000.0 * 1000.0); + timedOut = 1; + while (true) { + const long sliceResult = dispatch_group_wait(ctx->group, dispatch_time(DISPATCH_TIME_NOW, sliceNs)); + if (sliceResult == 0) { + timedOut = 0; + break; + } + const uint64_t nowUs = (uint64_t)(CFAbsoluteTimeGetCurrent() * 1000.0 * 1000.0); + if (nowUs - startUs >= timeoutUs) break; + InvokeHttpFetchYield(); + } + } else { + const dispatch_time_t deadline = dispatch_time(DISPATCH_TIME_NOW, + (int64_t)(timeoutSeconds * NSEC_PER_SEC)); + timedOut = dispatch_group_wait(ctx->group, deadline); + } + + const uint64_t endUs = (uint64_t)(CFAbsoluteTimeGetCurrent() * 1000.0 * 1000.0); + const uint64_t elapsedMs = endUs > startUs ? (endUs - startUs) / 1000ull : 0ull; + 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; + + // BFS (cold-boot seed) and list (HMR multi-URL) emit distinct shapes + // so the two waves are distinguishable in logs at a glance. + if (IsScriptLoadingLogEnabled()) { + if (recursive) { + Log(@"[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, + timedOut == 0 ? "drained" : "timeout", + maxConcurrent); + } else { + Log(@"[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, + timedOut == 0 ? "drained" : "timeout", + maxConcurrent); + } + } + + return timedOut == 0; +} + +bool KickstartHmrPrefetchSync(const std::string& seedUrl, + int maxConcurrent, + double timeoutSeconds, + size_t* outFetchedCount, + uint64_t* outElapsedMs) { + if (seedUrl.empty()) return false; + if (!IsRemoteUrlAllowed(seedUrl)) return false; + + std::vector seeds{seedUrl}; + return KickstartRunSync(std::move(seeds), + maxConcurrent, + timeoutSeconds, + /*recursive=*/true, + /*logLabel=*/"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; + + // Diagnostic seed — we record the first URL purely so the log line + // has a recognizable anchor when the user is correlating with their + // server-side `[hmr-ws][update] file=...` line. + std::string diagSeed; + for (const auto& u : urls) { + if (!u.empty()) { diagSeed = u; break; } + } + + std::vector copy = urls; + return KickstartRunSync(std::move(copy), + maxConcurrent, + timeoutSeconds, + /*recursive=*/false, + /*logLabel=*/"list", + diagSeed, + outFetchedCount, + outElapsedMs); +} + +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(); +} + +// ───────────────────────────────────────────────────────────── +// HMR + dev-session JS-callable globals +// +// Each `*Callback` below was previously an inline lambda in +// `Runtime::Init`. The lambdas captured nothing (`[]`), so the bodies +// transfer to file-local free functions unchanged. The single +// `InitializeHmrDevGlobals` entry point at the bottom installs them on +// the realm and is the only symbol exported to Runtime.mm. + +namespace { + +// Local helper that mirrors the `installGlobalFunction` lambda Runtime.mm +// used to define inline. Sets the function name on the v8 Function for +// nicer stack traces, attaches it to the realm's global object, and +// mirrors it onto globalThis so legacy `globalThis.__nsXxx(...)` callers +// keep working. +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(tns::ToV8String(isolate, name)); + context->Global() + ->Set(context, tns::ToV8String(isolate, name), fn) + .FromMaybe(false); + MirrorFunctionOnGlobalThis(isolate, context, name); +} + +// Run a dev-session module import and, on failure, publish a rejected +// promise carrying the original failure cause. Returns true when the +// module loaded successfully; on false the caller must return +// immediately because `info.GetReturnValue()` already holds the +// rejection. `logTag` is the bracketed prefix used in both the log +// line and the rejection message (e.g. `[__nsStartDevSession]`); +// `urlKind` is the human-readable subject (e.g. `clientUrl`). +bool RunModuleOrSendRejection(const v8::FunctionCallbackInfo& info, + Runtime* runtime, + v8::Local ctx, + const std::string& url, + const char* logTag, + const char* urlKind, + const std::string& sessionId, + bool logScriptLoading) { + v8::Isolate* isolate = info.GetIsolate(); + std::string err; + if (runtime->RunModule(url, &err)) { return true; } + const std::string causeText = err.empty() ? std::string("") : err; + if (logScriptLoading) { + Log(@"%s %s import failed session=%s url=%s message=%s", + logTag, urlKind, sessionId.c_str(), url.c_str(), causeText.c_str()); + } + std::string msg = std::string(logTag) + " failed to import " + + urlKind + ": " + url + " — " + causeText; + info.GetReturnValue().Set(CreateRejectedPromise( + ctx, v8::Exception::Error(tns::ToV8String(isolate, msg.c_str())))); + return false; +} + +void ConfigureDevRuntimeCallback(const v8::FunctionCallbackInfo& info) { + v8::Isolate* isolate = info.GetIsolate(); + v8::HandleScope scope(isolate); + v8::Local ctx = isolate->GetCurrentContext(); + bool logScriptLoading = tns::IsScriptLoadingLogEnabled(); + + if (info.Length() < 1 || !info[0]->IsObject()) { + if (logScriptLoading) { + Log(@"[__nsConfigureRuntime] expected config object argument"); + } + return; + } + + v8::Local config = info[0].As(); + + // Process importMap: can be a JSON string or an object with { imports: {...} } + v8::Local importMapKey = tns::ToV8String(isolate, "importMap"); + v8::Local importMapVal; + if (config->Get(ctx, importMapKey).ToLocal(&importMapVal) && !importMapVal->IsUndefined()) { + std::string jsonStr; + if (importMapVal->IsString()) { + v8::String::Utf8Value utf8(isolate, importMapVal); + if (*utf8) jsonStr = *utf8; + } else if (importMapVal->IsObject()) { + // Serialize object to JSON string + v8::Local jsonObj = ctx->Global()->Get(ctx, + tns::ToV8String(isolate, "JSON")).ToLocalChecked().As(); + v8::Local stringify = jsonObj->Get(ctx, + tns::ToV8String(isolate, "stringify")).ToLocalChecked().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) { + Log(@"[__nsConfigureRuntime] import map set (%zu bytes)", jsonStr.size()); + } + } + } + + // Process volatilePatterns: array of strings + v8::Local vpKey = tns::ToV8String(isolate, "volatilePatterns"); + v8::Local vpVal; + if (config->Get(ctx, vpKey).ToLocal(&vpVal) && vpVal->IsArray()) { + v8::Local arr = vpVal.As(); + std::vector patterns; + 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.push_back(*utf8); + } + } + if (!patterns.empty()) { + SetVolatilePatterns(patterns); + if (logScriptLoading) { + Log(@"[__nsConfigureRuntime] %zu volatile patterns set", patterns.size()); + } + } + } +} + +void StartDevSessionCallback(const v8::FunctionCallbackInfo& info) { + v8::Isolate* isolate = info.GetIsolate(); + v8::HandleScope scope(isolate); + v8::Local ctx = isolate->GetCurrentContext(); + + if (info.Length() < 1 || !info[0]->IsObject()) { + info.GetReturnValue().Set(CreateRejectedPromise( + ctx, v8::Exception::TypeError( + tns::ToV8String(isolate, + "[__nsStartDevSession] expected config object")))); + return; + } + + v8::Local config = info[0].As(); + tns::DevSessionState next; + std::string sessionError; + if (!tns::ReadDevSessionConfig(isolate, ctx, config, &next, &sessionError)) { + info.GetReturnValue().Set(CreateRejectedPromise( + ctx, v8::Exception::TypeError( + tns::ToV8String(isolate, sessionError.c_str())))); + return; + } + + tns::DevSessionState previous = tns::GetActiveDevSessionSnapshot(); + bool sessionChanged = tns::HasDevSessionChanged(previous, next); + bool logScriptLoading = tns::IsScriptLoadingLogEnabled(); + + if (sessionChanged && previous.active) { + std::vector staleUrls = tns::CollectSessionModuleUrls(previous); + if (logScriptLoading) { + Log(@"[__nsStartDevSession] session changed old=%s new=%s invalidating=%lu", + previous.sessionId.c_str(), next.sessionId.c_str(), + (unsigned long)staleUrls.size()); + } + if (!staleUrls.empty()) { + tns::InvalidateModules(isolate, ctx, staleUrls); + } + } + + if (!sessionChanged && previous.active && previous.started) { + if (logScriptLoading) { + Log(@"[__nsStartDevSession] session already active: %s", + next.sessionId.c_str()); + } + info.GetReturnValue().Set(CreateResolvedPromise(isolate, ctx)); + return; + } + + bool nativeRuntimeConfigDelegationEnabled = false; + { + v8::Local delegationFlag; + if (ctx->Global() + ->Get(ctx, tns::ToV8String(isolate, "__NS_EXPERIMENTAL_NATIVE_RUNTIME_CONFIG_URL__")) + .ToLocal(&delegationFlag) && + !delegationFlag.IsEmpty() && !delegationFlag->IsUndefined() && + !delegationFlag->IsNull()) { + nativeRuntimeConfigDelegationEnabled = delegationFlag->BooleanValue(isolate); + } + } + + if (!next.runtimeConfigUrl.empty() && nativeRuntimeConfigDelegationEnabled) { + if (logScriptLoading) { + Log(@"[__nsStartDevSession] runtimeConfigUrl fetch start session=%s url=%s", + next.sessionId.c_str(), next.runtimeConfigUrl.c_str()); + } + std::string runtimeConfigError; + if (!tns::ApplyDevRuntimeConfigFromUrl(next.runtimeConfigUrl, + &runtimeConfigError)) { + if (logScriptLoading) { + Log(@"[__nsStartDevSession] runtimeConfigUrl fetch failed session=%s url=%s", + next.sessionId.c_str(), next.runtimeConfigUrl.c_str()); + } + info.GetReturnValue().Set(CreateRejectedPromise( + ctx, v8::Exception::Error( + tns::ToV8String(isolate, runtimeConfigError.c_str())))); + return; + } + if (logScriptLoading) { + Log(@"[__nsStartDevSession] runtimeConfigUrl fetch complete session=%s url=%s", + next.sessionId.c_str(), next.runtimeConfigUrl.c_str()); + } + } else if (!next.runtimeConfigUrl.empty() && logScriptLoading) { + Log(@"[__nsStartDevSession] runtimeConfigUrl native delegation disabled; using JS-configured runtime session=%s url=%s", + next.sessionId.c_str(), next.runtimeConfigUrl.c_str()); + } + + tns::ApplyDevSessionGlobals(isolate, ctx, next); + + tns::StoreActiveDevSession(next); + + Runtime* runtime = Runtime::GetRuntime(isolate); + if (runtime == nullptr) { + if (logScriptLoading) { + Log(@"[__nsStartDevSession] runtime unavailable for session=%s", + next.sessionId.c_str()); + } + info.GetReturnValue().Set(CreateRejectedPromise( + ctx, v8::Exception::Error( + tns::ToV8String(isolate, + "[__nsStartDevSession] runtime unavailable")))); + return; + } + + if (logScriptLoading) { + Log(@"[__nsStartDevSession] clientUrl import start session=%s url=%s", + next.sessionId.c_str(), next.clientUrl.c_str()); + } + + // `RunModuleOrSendRejection` captures the failure cause from + // `RunModule` (`NativeScriptException::getMessage()`, top-level-await + // rejection reason, TLA timeout text, or empty-namespace hint) so the + // JS-side rejection carries the real reason instead of a generic + // "failed to import". + if (!RunModuleOrSendRejection(info, runtime, ctx, next.clientUrl, + "[__nsStartDevSession]", "clientUrl", + next.sessionId, logScriptLoading)) { + return; + } + + if (logScriptLoading) { + Log(@"[__nsStartDevSession] clientUrl import complete session=%s url=%s", + next.sessionId.c_str(), next.clientUrl.c_str()); + Log(@"[__nsStartDevSession] entryUrl import start session=%s url=%s", + next.sessionId.c_str(), next.entryUrl.c_str()); + } + + if (!RunModuleOrSendRejection(info, runtime, ctx, next.entryUrl, + "[__nsStartDevSession]", "entryUrl", + next.sessionId, logScriptLoading)) { + return; + } + + next.started = true; + tns::StoreActiveDevSession(next); + + if (logScriptLoading) { + Log(@"[__nsStartDevSession] entryUrl import complete session=%s url=%s", + next.sessionId.c_str(), next.entryUrl.c_str()); + Log(@"[__nsStartDevSession] session=%s imports complete; waiting for real app root commit", + next.sessionId.c_str()); + } + + if (logScriptLoading) { + Log(@"[__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()) { + Log(@"[__nsInvalidateModules] expected array of URL strings"); + return; + } + + v8::Local urlsArray = info[0].As(); + std::vector urls; + urls.reserve(urlsArray->Length()); + for (uint32_t index = 0; index < urlsArray->Length(); index++) { + v8::Local value; + if (!urlsArray->Get(ctx, index).ToLocal(&value) || !value->IsString()) { + continue; + } + + v8::String::Utf8Value utf8(isolate, value); + if (*utf8) { + urls.emplace_back(*utf8); + } + } + + // Permanent observability: surface every URL the runtime is asked to + // drop, plus a sample of currently-loaded module registry keys so we + // can correlate "asked to evict X" against "actually had X loaded as + // Y" when canonicalization differs (e.g. http://localhost vs + // file:// or http:// with port). Verbose-gated since per-event + // chatter is only useful while debugging an eviction mismatch. + if (tns::IsScriptLoadingLogEnabled()) { + Log(@"[ns-hmr][ios-invalidate] called urls.count=%zu", urls.size()); + size_t shown = 0; + for (const auto& u : urls) { + if (shown >= 32) break; + Log(@"[ns-hmr][ios-invalidate] url[%zu]=%s", shown, u.c_str()); + shown++; + } + if (urls.size() > shown) { + Log(@"[ns-hmr][ios-invalidate] (hidden %zu more URL(s))", urls.size() - shown); + } + } + + tns::InvalidateModules(isolate, ctx, urls); +} + +// +// `__nsKickstartHmrPrefetch(seedUrlOrUrls, options?)` lets HMR client +// tell the runtime "the next re-import will walk this dep tree — please +// pre-fill the loader cache with every reachable module body before V8 +// starts walking". Two argument shapes: +// +// 1. `seedUrl: string` — legacy cold-boot BFS from a single seed. +// 2. `urls: string[]` — HMR: dev server already precomputed the +// inverse-dep closure (`evictPaths` in the `ns:angular-update` +// payload); fetch that exact set in parallel with no body scan. +// +// Returns `{ ok, fetched, ms }` so JS can log the result. On failure +// callers should fall back to V8's normal synchronous walk. +void KickstartHmrPrefetchCallback(const v8::FunctionCallbackInfo& info) { + v8::Isolate* isolate = info.GetIsolate(); + v8::HandleScope scope(isolate); + v8::Local ctx = isolate->GetCurrentContext(); + + auto buildResult = [&](bool ok, size_t fetched, uint64_t elapsedMs) { + v8::Local result = v8::Object::New(isolate); + result->Set(ctx, tns::ToV8String(isolate, "ok"), v8::Boolean::New(isolate, ok)).Check(); + result->Set(ctx, tns::ToV8String(isolate, "fetched"), v8::Integer::NewFromUnsigned(isolate, (uint32_t)fetched)).Check(); + result->Set(ctx, tns::ToV8String(isolate, "ms"), v8::Number::New(isolate, (double)elapsedMs)).Check(); + info.GetReturnValue().Set(result); + }; + + if (info.Length() < 1 || (!info[0]->IsString() && !info[0]->IsArray())) { + Log(@"[__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, tns::ToV8String(isolate, "maxConcurrent")).ToLocal(&mcVal) && + !mcVal.IsEmpty() && 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, tns::ToV8String(isolate, "timeoutMs")).ToLocal(&toVal) && + !toVal.IsEmpty() && 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()) { + // Multi-URL form — non-recursive parallel fetch of the + // server-provided eviction closure. + 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 = tns::KickstartHmrPrefetchUrlsSync(urls, maxConcurrent, timeoutSeconds, &fetched, &elapsedMs); + buildResult(ok, fetched, elapsedMs); + return; + } + + // Single-string form — legacy BFS-from-seed. + v8::String::Utf8Value seedUtf8(isolate, info[0]); + if (!*seedUtf8) { + buildResult(false, 0, 0); + return; + } + std::string seedUrl(*seedUtf8); + + bool ok = tns::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(); + + tns::DevSessionState session = tns::GetActiveDevSessionSnapshot(); + if (!session.active || session.entryUrl.empty()) { + if (logScriptLoading) { + Log(@"[__nsReloadDevApp] no active dev session"); + } + info.GetReturnValue().Set(CreateRejectedPromise( + ctx, v8::Exception::Error( + tns::ToV8String(isolate, + "[__nsReloadDevApp] no active dev session")))); + return; + } + + std::vector sessionUrls = tns::CollectSessionModuleUrls(session); + if (logScriptLoading) { + Log(@"[__nsReloadDevApp] invalidating session=%s urls=%lu", + session.sessionId.c_str(), (unsigned long)sessionUrls.size()); + } + if (!sessionUrls.empty()) { + tns::InvalidateModules(isolate, ctx, sessionUrls); + } + + tns::SetDevSessionBootComplete(isolate, ctx, false); + + Runtime* runtime = Runtime::GetRuntime(isolate); + if (runtime == nullptr) { + if (logScriptLoading) { + Log(@"[__nsReloadDevApp] runtime unavailable for session=%s", + session.sessionId.c_str()); + } + info.GetReturnValue().Set(CreateRejectedPromise( + ctx, v8::Exception::Error( + tns::ToV8String(isolate, + "[__nsReloadDevApp] runtime unavailable")))); + return; + } + + if (logScriptLoading) { + Log(@"[__nsReloadDevApp] entryUrl import start session=%s url=%s", + session.sessionId.c_str(), session.entryUrl.c_str()); + } + + // Capture the inner failure cause so the reload's JS-side rejection + // carries the actual reason instead of a generic "failed to import" — + // symmetrical with the `__nsStartDevSession` path above so a + // single-file HMR reload that re-evaluates the entry surfaces + // TLA / module-load failures cleanly. + if (!RunModuleOrSendRejection(info, runtime, ctx, session.entryUrl, + "[__nsReloadDevApp]", "entryUrl", + session.sessionId, logScriptLoading)) { + return; + } + + if (logScriptLoading) { + Log(@"[__nsReloadDevApp] entryUrl import complete session=%s url=%s", + session.sessionId.c_str(), session.entryUrl.c_str()); + Log(@"[__nsReloadDevApp] session=%s reload imports complete; waiting for real app root commit (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(); + + // All [__nsApplyStyleUpdate] log surfaces below are gated on the + // logScriptLoading flag (DevFlags::IsScriptLoadingLogEnabled). This + // path runs on every CSS HMR apply, so we keep it silent unless the + // developer opts in via nativescript.config.ts. Real V8 exceptions + // are still surfaced via tns::LogError unconditionally so HMR + // failures are never swallowed. + const bool logEnabled = tns::IsScriptLoadingLogEnabled(); + + if (info.Length() < 1 || !info[0]->IsObject()) { + if (logEnabled) { + Log(@"[__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) { + Log(@"[__nsApplyStyleUpdate] missing cssText payload"); + } + return; + } + + v8::Local applicationValue; + if (!ctx->Global() + ->Get(ctx, tns::ToV8String(isolate, "Application")) + .ToLocal(&applicationValue) || + !applicationValue->IsObject()) { + if (logEnabled) { + Log(@"[__nsApplyStyleUpdate] Application is unavailable for %s", + url.c_str()); + } + return; + } + + v8::Local applicationObject = applicationValue.As(); + + v8::Local addCssValue; + if (!applicationObject + ->Get(ctx, tns::ToV8String(isolate, "addCss")) + .ToLocal(&addCssValue) || + !addCssValue->IsFunction()) { + if (logEnabled) { + Log(@"[__nsApplyStyleUpdate] Application.addCss is unavailable for %s", + url.c_str()); + } + return; + } + + v8::TryCatch tc(isolate); + v8::Local args[] = { + tns::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, tns::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, tns::ToV8String(isolate, "_onCssStateChange")) + .ToLocal(&cssStateChangeValue) && + cssStateChangeValue->IsFunction()) { + bool cssStateChanged = cssStateChangeValue.As() + ->Call(ctx, rootViewObject, 0, nullptr) + .ToLocal(&ignored); + (void)cssStateChanged; + } + } + } + } + + if (tc.HasCaught()) { + if (logEnabled) { + Log(@"[__nsApplyStyleUpdate] failed for %s", url.c_str()); + } + tns::LogError(isolate, tc); + return; + } + + if (logEnabled) { + Log(@"[__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 = tns::GetLoadedModuleUrls(); + v8::Local result = + v8::Array::New(isolate, static_cast(urls.size())); + + for (uint32_t index = 0; index < urls.size(); index++) { + result + ->Set(ctx, index, tns::ToV8String(isolate, urls[index].c_str())) + .FromMaybe(false); + } + + info.GetReturnValue().Set(result); +} + +} // namespace + +void InitializeHmrDevGlobals(v8::Isolate* isolate, v8::Local context) { + // Initialize HMR runtime helpers for dev mode. These collectively expose + // the JS-callable globals the @nativescript/vite HMR client uses to drain + // per-module callbacks and check declined-module state before each reboot: + // - __NS_DISPATCH_HOT_EVENT__ — fire registered import.meta.hot.on() listeners + // - __nsRunHmrDispose — drain import.meta.hot.dispose() callbacks + // - __nsRunHmrPrune — drain import.meta.hot.prune() callbacks + // - __nsHasDeclinedModule — check g_hotDeclined for full-reload fallback + // All four installations share one try/catch — they have identical risk + // profiles (single V8 function registration each) and a failure in any + // one of them shouldn't abort the rest of runtime init. + if (RuntimeConfig.IsDebug) { + try { + InitializeHotEventDispatcher(isolate, context); + InitializeHotDisposeRunner(isolate, context); + InitializeHotPruneRunner(isolate, context); + InitializeHotDeclinedHelper(isolate, context); + } catch (...) { + // Don't crash if HMR setup fails + } + } + + // Install the session bootstrap runtime configuration hook for import map + // support. `__nsConfigureDevRuntime` is the explicit host-runtime surface + // used by the deterministic session bootstrap. `__nsConfigureRuntime` + // remains as a compatibility alias while older entry paths still exist. + InstallGlobalFunction(isolate, context, "__nsConfigureDevRuntime", ConfigureDevRuntimeCallback); + InstallGlobalFunction(isolate, context, "__nsConfigureRuntime", ConfigureDevRuntimeCallback); + context->Global() + ->CreateDataProperty(context, + tns::ToV8String(isolate, "__nsSupportsRuntimeConfigUrl"), + v8::Boolean::New(isolate, true)) + .Check(); + + 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); } } // namespace tns diff --git a/NativeScript/runtime/ModuleInternal.h b/NativeScript/runtime/ModuleInternal.h index 9fae6239..d768049f 100644 --- a/NativeScript/runtime/ModuleInternal.h +++ b/NativeScript/runtime/ModuleInternal.h @@ -9,7 +9,14 @@ namespace tns { class ModuleInternal { public: ModuleInternal(v8::Local context); - bool RunModule(v8::Isolate* isolate, std::string path); + // When `outErrorMessage` is non-null, the failure cause is written into + // it on a false return: `NativeScriptException::getMessage()` for + // thrown exceptions, the V8 exception text for require() failures, the + // top-level-await rejection/timeout reason for ES modules, or a + // directional hint when the module returned an empty namespace without + // throwing. + bool RunModule(v8::Isolate* isolate, std::string path, + std::string* outErrorMessage = nullptr); void RunScript(v8::Isolate* isolate, std::string script); static v8::Local LoadScript(v8::Isolate* isolate, const std::string& path); diff --git a/NativeScript/runtime/ModuleInternal.mm b/NativeScript/runtime/ModuleInternal.mm index ae56a9bf..fb984443 100644 --- a/NativeScript/runtime/ModuleInternal.mm +++ b/NativeScript/runtime/ModuleInternal.mm @@ -1,15 +1,17 @@ #include "ModuleInternal.h" #import +#include #include #include #include #include #include #include "Caches.h" -#include "DevFlags.h" #include "Helpers.h" +#include "HMRSupport.h" #include "ModuleInternalCallbacks.h" // for ResolveModuleCallback #include "NativeScriptException.h" +#include "DevFlags.h" #include "Runtime.h" // for GetAppConfigValue #include "RuntimeConfig.h" @@ -17,6 +19,13 @@ namespace tns { +// Debug-mode JS-error flag. Was `extern` from Runtime.mm, whose definition +// (and its only consumer — the debug keepalive loop in runMainApplication) +// upstream removed in the #375 error-handling refactor. Defined locally so +// this branch's assignment sites stay intact across the merge; it is now +// write-only and can be deleted when this file is reconciled with #375. +bool jsErrorOccurred = false; + // Helper function to check if a module name looks like an optional external module bool IsLikelyOptionalModule(const std::string& moduleName) { // Check if it's a bare module name (no path separators) that could be an npm package @@ -33,6 +42,46 @@ bool IsESModule(const std::string& path) { !(path.size() >= 8 && path.compare(path.size() - 8, 8, ".mjs.map") == 0); } +static std::string NormalizePath(const std::string& path); + +static inline bool StartsWith(const std::string& value, const char* prefix) { + size_t n = strlen(prefix); + return value.size() >= n && value.compare(0, n, prefix) == 0; +} + +static std::string NormalizeHttpModuleUrl(const std::string& path) { + if (path.empty()) { + return path; + } + + std::string normalized = path; + if (StartsWith(normalized, "file://http://") || StartsWith(normalized, "file://https://")) { + normalized = normalized.substr(strlen("file://")); + } + + if (normalized.rfind("http:/", 0) == 0 && normalized.rfind("http://", 0) != 0) { + normalized.insert(5, "/"); + } else if (normalized.rfind("https:/", 0) == 0 && + normalized.rfind("https://", 0) != 0) { + normalized.insert(6, "/"); + } + + return normalized; +} + +static bool IsHttpModulePath(const std::string& path) { + std::string normalized = NormalizeHttpModuleUrl(path); + return StartsWith(normalized, "http://") || StartsWith(normalized, "https://"); +} + +static std::string CanonicalizeModulePath(const std::string& path) { + if (IsHttpModulePath(path)) { + return CanonicalizeHttpUrlKey(NormalizeHttpModuleUrl(path)); + } + + return NormalizePath(path); +} + // Normalize file system paths to a canonical representation so lookups in // g_moduleRegistry remain consistent regardless of how the path was provided. static std::string NormalizePath(const std::string& path) { @@ -143,10 +192,23 @@ bool IsESModule(const std::string& path) { } } -bool ModuleInternal::RunModule(Isolate* isolate, std::string path) { +// Forward `message` into the caller's optional out-param. The caller +// is responsible for any "missing message" presentation; this helper +// writes the raw value (which may be empty) when an out-param was +// supplied, and is a no-op otherwise. +static inline void SetOutErrorMessage(std::string* outErrorMessage, + const std::string& message) { + if (outErrorMessage != nullptr) { + *outErrorMessage = message; + } +} + +bool ModuleInternal::RunModule(Isolate* isolate, std::string path, + std::string* outErrorMessage) { std::shared_ptr cache = Caches::Get(isolate); Local context = cache->GetContext(); Local globalObject = context->Global(); + bool isHttpModule = IsHttpModulePath(path); // Ensure global.__dirname is defined so ESM/CommonJS shims relying on it work. { Local dirVal; @@ -163,20 +225,63 @@ bool IsESModule(const std::string& path) { } // ES module fast path - if (IsESModule(path)) { + if (IsESModule(path) || isHttpModule) { TryCatch tc(isolate); Local moduleNamespace; + if (isHttpModule && RuntimeConfig.IsDebug && IsScriptLoadingLogEnabled()) { + Log(@"[run-module][http-esm][begin] %s", NormalizeHttpModuleUrl(path).c_str()); + } try { moduleNamespace = ModuleInternal::LoadESModule(isolate, path); - } catch (NativeScriptException& ex) { - if (RuntimeConfig.IsDebug) { + } catch (const NativeScriptException& ex) { + if (isHttpModule && RuntimeConfig.IsDebug && IsScriptLoadingLogEnabled()) { + Log(@"[run-module][http-esm][exception] %s message=%s", + NormalizeHttpModuleUrl(path).c_str(), ex.getMessage().c_str()); + } + if (RuntimeConfig.IsDebug && !isHttpModule) { + Log(@"***** JavaScript exception occurred - detailed stack trace follows *****"); Log(@"Error loading ES module: %s", path.c_str()); Log(@"Exception: %s", ex.getMessage().c_str()); + Log(@"***** End stack trace - continuing execution *****"); + Log(@"Debug mode - ES module loading failed, but telling iOS it succeeded to prevent app termination"); + return true; // avoid termination in debug + } else { + // Surface the inner exception's message so callers passing + // `outErrorMessage` see the real cause instead of just a + // false return. + SetOutErrorMessage(outErrorMessage, ex.getMessage()); + return false; } - ex.ReThrowToV8(isolate); - return false; } - return true; + if (moduleNamespace.IsEmpty()) { + if (isHttpModule && RuntimeConfig.IsDebug && IsScriptLoadingLogEnabled()) { + Log(@"[run-module][http-esm][empty] %s", + NormalizeHttpModuleUrl(path).c_str()); + } + if (RuntimeConfig.IsDebug && !isHttpModule) { + Log(@"Debug mode - ES module returned empty namespace, but telling iOS it succeeded"); + return true; + } else { + // `LoadESModule` returned an empty value without throwing — + // typically a HTTP TLA timeout / rejection swallowed by the + // debug-modal path. Provide a directional hint so the JS + // rejection isn't empty; this is the only case where we + // *don't* have the actual reason text (see the rejection + // throw additions in `LoadESModule` to surface real causes + // when possible). + SetOutErrorMessage( + outErrorMessage, + std::string("ES module returned empty namespace for ") + path + + " — likely top-level await timeout or rejection swallowed by " + "debug error modal; check the device console for the matching " + "[esm][evaluate][promise-rejected:detail] or [esm][evaluate][promise-timeout] entry."); + return false; + } + } + if (isHttpModule && RuntimeConfig.IsDebug && IsScriptLoadingLogEnabled()) { + Log(@"[run-module][http-esm][ok] %s", NormalizeHttpModuleUrl(path).c_str()); + } + return true; // ES module loaded successfully } // For CommonJS modules (.js), use the traditional require() approach @@ -184,6 +289,8 @@ bool IsESModule(const std::string& path) { bool success = globalObject->Get(context, ToV8String(isolate, "require")).ToLocal(&requireObj); if (!success || !requireObj->IsFunction()) { Log(@"Warning: Failed to get require function from global object"); + SetOutErrorMessage(outErrorMessage, + "require function unavailable on globalThis"); return false; } Local requireFunc = requireObj.As(); @@ -196,17 +303,52 @@ bool IsESModule(const std::string& path) { if (!success || tc.HasCaught()) { if (RuntimeConfig.IsDebug) { + Log(@"***** JavaScript exception occurred - detailed stack trace follows *****"); Log(@"Error in require() call:"); Log(@" Requested module: '%s'", path.c_str()); Log(@" Called from: %s", RuntimeConfig.ApplicationPath.c_str()); + if (tc.HasCaught()) { tns::LogError(isolate, tc); } + + Log(@"***** End stack trace - continuing execution *****"); + Log(@"Debug mode - Main script execution failed, but telling iOS it succeeded to prevent " + @"app termination"); + + // Add a small delay to ensure error modal has time to render before we return + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.3 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + Log(@"🛡️ Debug mode - Crash prevention complete, app should remain stable"); + }); + + return true; // LIE TO iOS - return success to prevent app termination + } else { + // Best-effort extract the V8 exception text so the rejection + // upstream isn't empty. Leaves the out-param empty when the + // TryCatch has no exception to stringify; callers that need a + // placeholder string are expected to substitute one themselves. + std::string requireFailureMessage; + if (tc.HasCaught()) { + Local ex = tc.Exception(); + if (!ex.IsEmpty()) { + v8::Local exStr; + if (ex->ToString(context).ToLocal(&exStr)) { + v8::String::Utf8Value utf8(isolate, exStr); + if (*utf8) { + requireFailureMessage.assign(*utf8, utf8.length()); + } + } + } + } + if (requireFailureMessage.empty()) { + requireFailureMessage = + std::string("require() failed for module ") + path; + } + SetOutErrorMessage(outErrorMessage, requireFailureMessage); + return false; } - if (tc.HasCaught()) { - tc.ReThrow(); - } - return false; } return success; @@ -226,13 +368,33 @@ bool IsESModule(const std::string& path) { bool success = requireFuncFactory->Call(context, thiz, 2, args).ToLocal(&result); if (!success || tc.HasCaught()) { if (tc.HasCaught()) { - throw NativeScriptException(isolate, tc, "Failed to call require factory function"); + tns::LogError(isolate, tc); } - throw NativeScriptException(isolate, "Failed to call require factory function"); + Log(@"FATAL: Failed to call require factory function"); + // Return a dummy function to avoid further crashes + result = v8::Function::New(context, [](const v8::FunctionCallbackInfo& info) { + if (RuntimeConfig.IsDebug) { + Log(@"Debug mode - Require function unavailable (factory failed)"); + info.GetReturnValue().SetUndefined(); + } else { + info.GetIsolate()->ThrowException(v8::Exception::Error( + tns::ToV8String(info.GetIsolate(), "Require function unavailable"))); + } + }).ToLocalChecked(); } if (result.IsEmpty() || !result->IsFunction()) { - throw NativeScriptException(isolate, "Require factory did not return a function"); + Log(@"FATAL: Require factory did not return a function"); + // Return a dummy function + result = v8::Function::New(context, [](const v8::FunctionCallbackInfo& info) { + if (RuntimeConfig.IsDebug) { + Log(@"Debug mode - Require function unavailable (no function returned)"); + info.GetReturnValue().SetUndefined(); + } else { + info.GetIsolate()->ThrowException(v8::Exception::Error( + tns::ToV8String(info.GetIsolate(), "Require function unavailable"))); + } + }).ToLocalChecked(); } return result.As(); @@ -253,9 +415,7 @@ bool IsESModule(const std::string& path) { if (*s) { moduleName.assign(*s, s.length()); if (moduleName.rfind("http://", 0) == 0 || moduleName.rfind("https://", 0) == 0) { - std::string msg = - std::string("NativeScript: require() of URL module is not supported: ") + moduleName + - ". Use dynamic import() instead."; + std::string msg = std::string("NativeScript: require() of URL module is not supported: ") + moduleName + ". Use dynamic import() instead."; throw NativeScriptException(msg.c_str()); } } @@ -310,6 +470,7 @@ bool IsESModule(const std::string& path) { fullPath = [NSString stringWithUTF8String:moduleName.c_str()]; } + NSString* fileNameOnly = [fullPath lastPathComponent]; NSString* pathOnly = [fullPath stringByDeletingLastPathComponent]; @@ -483,13 +644,62 @@ bool IsESModule(const std::string& path) { // Compile/load the JavaScript/ESM source Local scriptValue = LoadScript(isolate, modulePath); + // Check if script loading failed (debug mode graceful returns) + if (scriptValue.IsEmpty()) { + if (RuntimeConfig.IsDebug) { + // NSLog(@"Debug mode - Script loading returned empty value, returning gracefully: %s", + // modulePath.c_str()); + return Local(); + } else { + throw NativeScriptException(isolate, "Script loading failed for " + modulePath); + } + } + // Check if this is an ES module bool isESM = IsESModule(modulePath); std::shared_ptr cache = Caches::Get(isolate); if (isESM) { + // For ES modules, the returned value is the namespace object + + // First check if scriptValue is empty (from debug mode graceful returns) + if (scriptValue.IsEmpty()) { + if (RuntimeConfig.IsDebug) { + Log(@"Debug mode - ES module returned empty value, returning gracefully: %s", + modulePath.c_str()); + return Local(); + } else { + throw NativeScriptException(isolate, "ES module load returned empty value " + modulePath); + } + } + if (!scriptValue->IsObject()) { - throw NativeScriptException(isolate, "Failed to load ES module " + modulePath); + if (RuntimeConfig.IsDebug) { + Log(@"Debug mode - ES module load failed, returning gracefully: %s", modulePath.c_str()); + // Return empty module object to prevent crashes + return Local(); + } else { + throw NativeScriptException(isolate, "Failed to load ES module " + modulePath); + } + } + + // Debug: Check if we're in a worker context and if self.onmessage is set + std::shared_ptr cache = Caches::Get(isolate); + if (cache->isWorker) { + Local context = isolate->GetCurrentContext(); + Local global = context->Global(); + + // Check if self exists + Local selfValue; + if (global->Get(context, ToV8String(isolate, "self")).ToLocal(&selfValue)) { + if (selfValue->IsObject()) { + Local selfObj = selfValue.As(); + Local onmessageValue; + if (selfObj->Get(context, ToV8String(isolate, "onmessage")).ToLocal(&onmessageValue)) { + // onmessage exists + } + } + } } // Handle exports differently for ES modules vs worker scripts @@ -543,8 +753,7 @@ throw NativeScriptException(isolate, // Shorten the parentDir for GetRequireFunction to avoid V8 parsing issues with long paths std::string shortParentDir; if (parentDir.length() >= RuntimeConfig.ApplicationPath.length() && - parentDir.compare(0, RuntimeConfig.ApplicationPath.length(), RuntimeConfig.ApplicationPath) == - 0) { + parentDir.compare(0, RuntimeConfig.ApplicationPath.length(), RuntimeConfig.ApplicationPath) == 0) { shortParentDir = "/app" + parentDir.substr(RuntimeConfig.ApplicationPath.length()); } else { // Fallback: use the entire path if it doesn't start with ApplicationPath @@ -617,19 +826,65 @@ throw NativeScriptException(isolate, Local