From 8310eac9b0987987830402cbe27627474b03a565 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Fri, 22 May 2026 09:59:02 -0700 Subject: [PATCH] feat: HMR dev-sessions, ESM resolver hardening, dev-mode runtime globals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Hot Module Replacement runtime layer plus the supporting ESM resolver hardening and dev-session globals that make hot reload viable on 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 | 394 +- .../runtime/ModuleInternalCallbacks.h | 33 +- .../runtime/ModuleInternalCallbacks.mm | 2174 ++++++++--- NativeScript/runtime/Runtime.h | 5 +- NativeScript/runtime/Runtime.mm | 206 +- NativeScript/runtime/URLImpl.cpp | 82 + NativeScript/runtime/URLImpl.h | 11 +- NativeScript/runtime/Worker.h | 7 + NativeScript/runtime/Worker.mm | 74 + TestRunner/app/tests/HttpEsmLoaderTests.js | 323 +- .../Jasmine/jasmine-2.0.1/boot.js | 9 + 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, 6455 insertions(+), 918 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 b55a5cb2..52d75fd0 100644 --- a/NativeScript/runtime/ModuleInternal.mm +++ b/NativeScript/runtime/ModuleInternal.mm @@ -1,5 +1,6 @@ #include "ModuleInternal.h" #import +#include #include #include #include @@ -7,6 +8,7 @@ #include #include "Caches.h" #include "Helpers.h" +#include "HMRSupport.h" #include "ModuleInternalCallbacks.h" // for ResolveModuleCallback #include "NativeScriptException.h" #include "DevFlags.h" @@ -36,6 +38,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) { @@ -146,10 +188,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; @@ -166,13 +221,20 @@ 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 (const NativeScriptException& ex) { - if (RuntimeConfig.IsDebug) { + 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()); @@ -180,17 +242,41 @@ bool IsESModule(const std::string& path) { 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; } } if (moduleNamespace.IsEmpty()) { - if (RuntimeConfig.IsDebug) { + 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 } @@ -199,6 +285,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(); @@ -233,7 +321,28 @@ bool IsESModule(const std::string& path) { return true; // LIE TO iOS - return success to prevent app termination } else { - // In release mode, still fail as before + // 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; } } @@ -848,26 +957,53 @@ ScriptOrigin origin(isolate, urlString, } Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& path) { - std::string canonicalPath = NormalizePath(path); + bool isHttpModule = IsHttpModulePath(path); + std::string canonicalPath = CanonicalizeModulePath(path); + std::string requestPath = isHttpModule ? NormalizeHttpModuleUrl(path) : canonicalPath; auto context = isolate->GetCurrentContext(); - // 1) Prepare URL & source - std::string base = ReplaceAll(canonicalPath, RuntimeConfig.BaseDir, ""); - std::string url = "file://" + base; - v8::Local sourceText = ModuleInternal::WrapModuleContent(isolate, canonicalPath); - auto* cacheData = ModuleInternal::LoadScriptCache(canonicalPath); + auto describeModuleStatus = [](Module::Status status) -> const char* { + switch (status) { + case Module::kUninstantiated: + return "uninstantiated"; + case Module::kInstantiating: + return "instantiating"; + case Module::kInstantiated: + return "instantiated"; + case Module::kEvaluating: + return "evaluating"; + case Module::kEvaluated: + return "evaluated"; + case Module::kErrored: + return "errored"; + } - Local urlString; - if (!v8::String::NewFromUtf8(isolate, url.c_str(), NewStringType::kNormal).ToLocal(&urlString)) { - throw NativeScriptException(isolate, "Failed to create URL string for ES module " + canonicalPath); - } + return "unknown"; + }; - ScriptOrigin origin(isolate, urlString, 0, 0, false, -1, Local(), false, false, - true // ← is_module - ); - ScriptCompiler::Source source(sourceText, origin, cacheData); + auto existingIt = g_moduleRegistry.find(canonicalPath); + if (existingIt != g_moduleRegistry.end()) { + Local existing = existingIt->second.Get(isolate); + if (existing.IsEmpty()) { + if (RuntimeConfig.IsDebug && IsScriptLoadingLogEnabled()) { + Log(@"[esm][cache] dropping empty registry entry %s", canonicalPath.c_str()); + } + RemoveModuleFromRegistry(canonicalPath); + } else { + Module::Status existingStatus = existing->GetStatus(); + if (RuntimeConfig.IsDebug && IsScriptLoadingLogEnabled()) { + Log(@"[esm][cache] hit %s status=%s", canonicalPath.c_str(), + describeModuleStatus(existingStatus)); + } + if (existingStatus == Module::kErrored) { + RemoveModuleFromRegistry(canonicalPath); + } else if (existingStatus == Module::kEvaluated) { + UpdateModuleFallback(isolate, canonicalPath, existing); + return existing->GetModuleNamespace(); + } + } + } - // 2) Compile with its own TryCatch // Phase diagnostics helper (local lambda) – only active in debug builds when logScriptLoading is enabled auto logPhase = [&](const char* phase, const char* status, const char* classification = "", const char* extra = "") { if (RuntimeConfig.IsDebug && IsScriptLoadingLogEnabled()) { @@ -886,66 +1022,100 @@ ScriptOrigin origin(isolate, urlString, 0, 0, false, -1, Local(), false, } } }; - logPhase("compile", "begin"); Local module; - { - TryCatch tcCompile(isolate); - MaybeLocal maybeMod = ScriptCompiler::CompileModule( - isolate, &source, - cacheData ? ScriptCompiler::kConsumeCodeCache : ScriptCompiler::kNoCompileOptions); - + ScriptCompiler::CachedData* cacheData = nullptr; + if (isHttpModule) { + logPhase("compile", "delegate-http"); + MaybeLocal maybeMod = LoadHttpModuleForUrl(isolate, context, requestPath); if (!maybeMod.ToLocal(&module)) { - // Attempt classification heuristics - const char* classification = "unknown"; - if (tcCompile.HasCaught()) { - Local msg = tcCompile.Message(); - if (!msg.IsEmpty()) { - v8::String::Utf8Value w(isolate, msg->Get()); - if (*w) { - std::string m(*w); - if (m.find("Unexpected token") != std::string::npos || m.find("SyntaxError") != std::string::npos) classification = "syntax"; - else if (m.find("Cannot use import statement outside a module") != std::string::npos) classification = "not-a-module"; - } - } - } - logPhase("compile", "fail", classification); - // V8 threw a syntax error or similar + logPhase("compile", "fail", "http-loader"); if (RuntimeConfig.IsDebug) { - // Log the detailed JavaScript error with full stack trace - Log(@"***** JavaScript exception occurred *****"); - Log(@"Error compiling ES module: %s", canonicalPath.c_str()); - if (tcCompile.HasCaught()) { - tns::LogError(isolate, tcCompile); - } - Log(@"***** Debug mode - continuing execution *****"); - Log(@"ES module compilation failed: %s", canonicalPath.c_str()); - // Return empty to prevent crashes return Local(); - } else { - throw NativeScriptException(isolate, tcCompile, "Cannot compile ES module " + canonicalPath); } + throw NativeScriptException("Cannot load ES module " + canonicalPath); } - } - logPhase("compile", "ok"); + logPhase("compile", "ok", "http-loader"); - // 3) Register for resolution callback - extern std::unordered_map> g_moduleRegistry; - - // Safe Global handle management: Clear any existing entry first - auto it = g_moduleRegistry.find(canonicalPath); - if (it != g_moduleRegistry.end()) { - // Clear the existing Global handle before replacing it - it->second.Reset(); - } + if (module->GetStatus() == Module::kEvaluated) { + UpdateModuleFallback(isolate, canonicalPath, module); + return module->GetModuleNamespace(); + } + } else { + std::string url; + std::string base = ReplaceAll(canonicalPath, RuntimeConfig.BaseDir, ""); + url = "file://" + base; + v8::Local sourceText = ModuleInternal::WrapModuleContent(isolate, canonicalPath); + cacheData = ModuleInternal::LoadScriptCache(canonicalPath); + + Local urlString; + if (!v8::String::NewFromUtf8(isolate, url.c_str(), NewStringType::kNormal).ToLocal(&urlString)) { + throw NativeScriptException(isolate, "Failed to create URL string for ES module " + canonicalPath); + } - // Now safely set the new module handle - g_moduleRegistry[canonicalPath].Reset(isolate, module); + ScriptOrigin origin(isolate, urlString, 0, 0, false, -1, Local(), false, false, + true // ← is_module + ); + ScriptCompiler::Source source(sourceText, origin, cacheData); + + logPhase("compile", "begin"); + { + TryCatch tcCompile(isolate); + MaybeLocal maybeMod = ScriptCompiler::CompileModule( + isolate, &source, + cacheData ? ScriptCompiler::kConsumeCodeCache : ScriptCompiler::kNoCompileOptions); + + if (!maybeMod.ToLocal(&module)) { + // Attempt classification heuristics + const char* classification = "unknown"; + if (tcCompile.HasCaught()) { + Local msg = tcCompile.Message(); + if (!msg.IsEmpty()) { + v8::String::Utf8Value w(isolate, msg->Get()); + if (*w) { + std::string m(*w); + if (m.find("Unexpected token") != std::string::npos || m.find("SyntaxError") != std::string::npos) classification = "syntax"; + else if (m.find("Cannot use import statement outside a module") != std::string::npos) classification = "not-a-module"; + } + } + } + logPhase("compile", "fail", classification); + // V8 threw a syntax error or similar + if (RuntimeConfig.IsDebug) { + // Log the detailed JavaScript error with full stack trace + Log(@"***** JavaScript exception occurred *****"); + Log(@"Error compiling ES module: %s", canonicalPath.c_str()); + if (tcCompile.HasCaught()) { + tns::LogError(isolate, tcCompile); + } + Log(@"***** Debug mode - continuing execution *****"); + Log(@"ES module compilation failed: %s", canonicalPath.c_str()); + // Return empty to prevent crashes + return Local(); + } else { + throw NativeScriptException(isolate, tcCompile, "Cannot compile ES module " + canonicalPath); + } + } + } + logPhase("compile", "ok"); + + // Register for resolution callback + auto it = g_moduleRegistry.find(canonicalPath); + if (RuntimeConfig.IsDebug && IsScriptLoadingLogEnabled() && + (requestPath != canonicalPath || path != canonicalPath)) { + Log(@"[esm][register] raw=%s request=%s canonical=%s url=%s existing=%s", + path.c_str(), requestPath.c_str(), canonicalPath.c_str(), url.c_str(), + it != g_moduleRegistry.end() ? "yes" : "no"); + } + if (it != g_moduleRegistry.end()) { + it->second.Reset(); + } + g_moduleRegistry[canonicalPath].Reset(isolate, module); - // 4) Save cache if first time - if (cacheData == nullptr) { - Local unbound = module->GetUnboundModuleScript(); - auto* generatedCache = ScriptCompiler::CreateCodeCache(unbound); - ModuleInternal::SaveScriptCache(generatedCache, canonicalPath); + if (cacheData == nullptr) { + Local unbound = module->GetUnboundModuleScript(); + auto* generatedCache = ScriptCompiler::CreateCodeCache(unbound); + ModuleInternal::SaveScriptCache(generatedCache, canonicalPath); + } } // 5) Instantiate (link) with its own TryCatch @@ -1036,19 +1206,35 @@ ScriptOrigin origin(isolate, urlString, 0, 0, false, -1, Local(), false, TryCatch promiseTc(isolate); Local promise = result.As(); - // Process microtasks to allow Promise resolution (for both worker and main contexts) - int maxAttempts = 100; - int attempts = 0; - - while (attempts < maxAttempts && !promiseTc.HasCaught()) { + // Top-level await can depend on native async work such as fetch(), which requires + // both V8 microtasks and the Cocoa run loop to advance. Returning early here causes + // callers like __nsStartDevSession(clientUrl) to continue before bootstrap finished. + auto pumpAsyncProgress = [&]() { isolate->PerformMicrotaskCheckpoint(); + if (isHttpModule) { + @autoreleasepool { + NSRunLoop* runLoop = [NSThread isMainThread] ? [NSRunLoop mainRunLoop] : [NSRunLoop currentRunLoop]; + NSDate* sliceDeadline = [NSDate dateWithTimeIntervalSinceNow:0.01]; + [runLoop runMode:NSDefaultRunLoopMode beforeDate:sliceDeadline]; + } + isolate->PerformMicrotaskCheckpoint(); + } + }; + + const NSTimeInterval timeoutSeconds = isHttpModule ? 10.0 : 1.0; + NSDate* deadline = [NSDate dateWithTimeIntervalSinceNow:timeoutSeconds]; + bool settled = false; + + while (!promiseTc.HasCaught()) { + pumpAsyncProgress(); if (promiseTc.HasCaught()) { break; } - Promise::PromiseState state = promise->State(); + Promise::PromiseState state = promise->State(); if (state != Promise::kPending) { + settled = true; if (state == Promise::kRejected) { RemoveModuleFromRegistry(canonicalPath); logPhase("evaluate", "promise-rejected"); @@ -1128,7 +1314,25 @@ ScriptOrigin origin(isolate, urlString, 0, 0, false, -1, Local(), false, NativeScriptException::ShowErrorModal(isolate, errorTitle, errorMessage, stackTrace); logPhase("evaluate", "promise-rejected-handled"); - // In debug mode, don't throw any exceptions - just return empty value + // For HTTP modules we throw even in debug so the + // rejection reason can propagate back through + // `ModuleInternal::RunModule`'s catch handler into the + // caller's `outErrorMessage`; otherwise the caller sees + // only a generic failure with no detail. For non-HTTP + // debug we preserve the historical "show modal + return + // empty namespace" behavior — `RunModule`'s + // empty-namespace branch then returns true so the app + // keeps running. + if (isHttpModule) { + std::string detail = std::string("HTTP module evaluation promise rejected: ") + canonicalPath; + if (!errorMessage.empty()) { + detail += " — "; + detail += errorMessage; + } + throw NativeScriptException(detail); + } + + // Non-HTTP debug: don't throw, just return empty. return Local(); } else { // Release mode - throw exceptions as before @@ -1140,18 +1344,40 @@ ScriptOrigin origin(isolate, urlString, 0, 0, false, -1, Local(), false, } } if (IsScriptLoadingLogEnabled()) { - Log("LoadESModule: Promise resolved successfully\n"); + logPhase("evaluate", "promise-resolved"); } break; } - attempts++; - usleep(100); // 0.1ms delay + if ([deadline timeIntervalSinceNow] <= 0) { + break; + } + + if (!isHttpModule) { + usleep(1000); // 1ms delay for non-HTTP top-level await polling + } } - // Timeout: continue; the host event loop will settle microtasks later - if (attempts >= maxAttempts) { - printf("LoadESModule: Promise resolution timeout, continuing anyway\n"); + if (!settled && promise->State() == Promise::kPending) { + logPhase("evaluate", "promise-timeout"); + if (isHttpModule) { + RemoveModuleFromRegistry(canonicalPath); + // Throw even in debug so the TLA timeout reason flows + // through `ModuleInternal::RunModule`'s catch handler and + // into `__nsStartDevSession`'s JS-side rejection. Pre-fix + // the debug path returned an empty namespace silently, + // which the dev session reported as a generic "failed to + // import" with no clue that TLA had timed out. + if (RuntimeConfig.IsDebug) { + Log(@"***** JavaScript exception occurred *****"); + Log(@"Top-level await timed out for HTTP ES module: %s", canonicalPath.c_str()); + Log(@"***** Debug mode - surfacing as exception so HMR dev session sees the reason *****"); + } + + std::string timeoutMessage = "Top-level await timed out for HTTP ES module "; + timeoutMessage += canonicalPath; + throw NativeScriptException(timeoutMessage); + } } } } diff --git a/NativeScript/runtime/ModuleInternalCallbacks.h b/NativeScript/runtime/ModuleInternalCallbacks.h index 3d0df014..a1c2cfb0 100644 --- a/NativeScript/runtime/ModuleInternalCallbacks.h +++ b/NativeScript/runtime/ModuleInternalCallbacks.h @@ -4,19 +4,38 @@ #include #include +#include namespace tns { -// Export our registry so both LoadESModule and the callback see the same data: -extern std::unordered_map> g_moduleRegistry; +// Export our registry so both LoadESModule and the callback see the same data. +// `thread_local`: each NS isolate (main thread + each Worker thread) gets its +// own per-thread map, because v8::Global handles are isolate-bound. +// See the long-form comment above the definition in ModuleInternalCallbacks.mm +// for the cross-isolate-handle bug this prevents. +extern thread_local std::unordered_map>& g_moduleRegistry; // Utility to drop modules from the registry when compilation/instantiation fails void RemoveModuleFromRegistry(const std::string& canonicalPath); +// Authoritative HTTP URL loader for dev-served ESM. This compiles and registers +// the module under its canonical URL key without evaluating it. +v8::MaybeLocal LoadHttpModuleForUrl( + v8::Isolate* isolate, v8::Local context, + const std::string& requestedUrl); + // Keep a fallback copy of the last evaluated module so could be served while reloading if needed void UpdateModuleFallback(v8::Isolate* isolate, const std::string& canonicalPath, v8::Local module); +// Drop exact URL-keyed modules from the registry and clear any in-flight +// invalidation bookkeeping tied to those canonical keys. +void InvalidateModules(v8::Isolate* isolate, v8::Local context, + const std::vector& urls); + +// Diagnostics helper: returns URL-like keys currently loaded in the module registry. +std::vector GetLoadedModuleUrls(); + // Resolve callback signature (with import‑assertions slot) v8::MaybeLocal ResolveModuleCallback( v8::Local context, v8::Local specifier, @@ -29,4 +48,14 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( v8::Local specifier, v8::Local import_assertions); +// Import map support +// Parse and store an import map from JSON. Expected shape: {"imports": {"key": "value", ...}} +void SetImportMap(const std::string& json); + +// Set URL patterns that should bypass module cache (e.g. "/@ns/sfc/", "?v=") +void SetVolatilePatterns(const std::vector& patterns); + +// Clear import map state and vendor module cache. Must be called before isolate disposal. +void CleanupImportMapGlobals(); + } // namespace tns diff --git a/NativeScript/runtime/ModuleInternalCallbacks.mm b/NativeScript/runtime/ModuleInternalCallbacks.mm index 880d4613..53d0d562 100644 --- a/NativeScript/runtime/ModuleInternalCallbacks.mm +++ b/NativeScript/runtime/ModuleInternalCallbacks.mm @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -101,6 +102,10 @@ static inline bool StartsWith(const std::string& s, const char* prefix) { return s.size() >= n && s.compare(0, n, prefix) == 0; } +static bool ShouldTraceRegistryKey(const std::string& rawKey, const std::string& registryKey); +static std::string CanonicalizeRegistryKey(const std::string& key); +static const char* ModuleStatusToString(v8::Module::Status status); + static v8::MaybeLocal CompileModuleFromSource(v8::Isolate* isolate, v8::Local context, const std::string& code, const std::string& urlStr) { @@ -129,59 +134,6 @@ static inline bool StartsWith(const std::string& s, const char* prefix) { return hs.Escape(mod); } -// Like CompileModuleFromSource, but registers the module into g_moduleRegistry under urlStr -// immediately after compilation and before instantiation. This allows cyclic imports to -// resolve to the same in-progress module instance without refetching. -static v8::MaybeLocal CompileModuleFromSourceRegisterFirst(v8::Isolate* isolate, - v8::Local context, - const std::string& code, - const std::string& urlStr) { - v8::EscapableHandleScope hs(isolate); - v8::TryCatch tc(isolate); - v8::Local sourceText = tns::ToV8String(isolate, code.c_str()); - v8::Local urlV8; - if (!v8::String::NewFromUtf8(isolate, urlStr.c_str(), v8::NewStringType::kNormal).ToLocal(&urlV8)) { - return v8::MaybeLocal(); - } - v8::ScriptOrigin origin(isolate, urlV8, 0, 0, false, -1, v8::Local(), false, false, true); - v8::ScriptCompiler::Source src(sourceText, origin); - v8::Local mod; - if (!v8::ScriptCompiler::CompileModule(isolate, &src).ToLocal(&mod)) { - return v8::MaybeLocal(); - } - // If an entry already exists for urlStr, do not overwrite it—use that module instead - auto itExisting = g_moduleRegistry.find(urlStr); - if (itExisting != g_moduleRegistry.end()) { - v8::Local existing = itExisting->second.Get(isolate); - if (!existing.IsEmpty()) { - return hs.Escape(existing); - } - } - // Register immediately so cycles see the same instance - g_moduleRegistry[urlStr].Reset(isolate, mod); - // Instantiate - if (mod->GetStatus() == v8::Module::kUninstantiated) { - if (!mod->InstantiateModule(context, &ResolveModuleCallback).FromMaybe(false)) { - // Cleanup on failure to avoid leaving broken entries - RemoveModuleFromRegistry(urlStr); - return v8::MaybeLocal(); - } - } - // Evaluate if needed - if (mod->GetStatus() != v8::Module::kEvaluated) { - if (mod->Evaluate(context).IsEmpty()) { - RemoveModuleFromRegistry(urlStr); - return v8::MaybeLocal(); - } - } - // If any exception was caught, clean and bail - if (tc.HasCaught()) { - RemoveModuleFromRegistry(urlStr); - return v8::MaybeLocal(); - } - return hs.Escape(mod); -} - // Compile-only variant for use inside ResolveModuleCallback. It compiles a v8::Module and // registers it under urlStr but does NOT instantiate or evaluate. V8 is currently instantiating // the importer and will handle instantiation of this dependency. @@ -190,6 +142,11 @@ static inline bool StartsWith(const std::string& s, const char* prefix) { const std::string& code, const std::string& urlStr) { v8::EscapableHandleScope hs(isolate); + const std::string registryKey = CanonicalizeRegistryKey(urlStr); + if (IsScriptLoadingLogEnabled() && ShouldTraceRegistryKey(urlStr, registryKey)) { + Log(@"[resolver][register-resolve-only] raw=%s key=%s", urlStr.c_str(), + registryKey.c_str()); + } v8::Local sourceText = tns::ToV8String(isolate, code.c_str()); v8::Local urlV8; if (!v8::String::NewFromUtf8(isolate, urlStr.c_str(), v8::NewStringType::kNormal).ToLocal(&urlV8)) { @@ -239,27 +196,637 @@ static inline bool StartsWith(const std::string& s, const char* prefix) { } } // If an entry already exists, reuse it - auto itExisting = g_moduleRegistry.find(urlStr); + auto itExisting = g_moduleRegistry.find(registryKey); if (itExisting != g_moduleRegistry.end()) { v8::Local existing = itExisting->second.Get(isolate); if (!existing.IsEmpty()) { return hs.Escape(existing); } } - g_moduleRegistry[urlStr].Reset(isolate, mod); + g_moduleRegistry[registryKey].Reset(isolate, mod); return hs.Escape(mod); } // ──────────────────────────────────────────────────────────────────────────── -// Simple in-process registry: maps absolute file paths → compiled Module handles -std::unordered_map> g_moduleRegistry; -static std::unordered_map> g_moduleFallbackRegistry; -static std::unordered_map> g_moduleFallbackByRelative; +// Per-isolate (thread-local) module registries: map absolute file paths / +// canonical URLs → compiled v8::Module handles for the *current* isolate. +// +// Why thread_local: NS Worker creates a separate v8::Isolate on its own +// thread (see Worker::ConstructorCallback in Worker.mm). v8::Global +// handles are bound to the isolate that created them; reading their +// internal state from a different isolate is undefined behaviour. A +// previous design held these registries as a single, process-global map, +// which under HMR (where the worker fetches the SAME `/ns/m/` URLs the +// main thread already loaded) caused the worker isolate to receive a +// Module compiled by the main isolate. V8's linker then read the cross- +// isolate Module's export table and emitted bogus errors like: +// SyntaxError: The requested module 'X' does not provide an export named 'Y' +// even though the served source clearly declared `Y`. Making the +// registry thread_local keeps each NS runtime/worker walking its own +// fresh, valid handle graph. +// +// Why the leaky-pointer pattern (heap-allocated, never deleted by the +// thread-exit destructor): a `thread_local std::unordered_map<..., +// v8::Global<...>>` would, on thread exit, run the map's destructor — +// which iterates and calls v8::Global::Reset() on each handle. If the +// owning isolate was already torn down, those Resets blow up with the +// `__cxa_finalize_ranges` SIGSEGV/SIGBUS that the original comment +// warned about. By holding a thread_local *pointer* to a heap-allocated +// map, the variable's per-thread destructor is a no-op (it just drops +// a pointer); cleanup of the actual handles is handled explicitly by +// the Runtime destructor (Runtime.mm) and CleanupImportMapGlobals() +// below, which run *before* the isolate is disposed. +// +// The reference aliases below keep all existing access sites unchanged +// (no `()` or `->` rewrites needed across ~100+ call sites). On each +// thread's first use of e.g. `g_moduleRegistry`, the initializer below +// runs once per thread to bind the reference to that thread's map. +namespace { +using ModuleHandleMap = std::unordered_map>; + +ModuleHandleMap& MakePerIsolateModuleRegistry() { + thread_local auto* p = new ModuleHandleMap(); + return *p; +} + +ModuleHandleMap& MakePerIsolateModuleFallbackRegistry() { + thread_local auto* p = new ModuleHandleMap(); + return *p; +} + +ModuleHandleMap& MakePerIsolateModuleFallbackByRelative() { + thread_local auto* p = new ModuleHandleMap(); + return *p; +} +} // namespace + +thread_local std::unordered_map>& g_moduleRegistry = MakePerIsolateModuleRegistry(); +static thread_local std::unordered_map>& g_moduleFallbackRegistry = MakePerIsolateModuleFallbackRegistry(); +static thread_local std::unordered_map>& g_moduleFallbackByRelative = MakePerIsolateModuleFallbackByRelative(); + +// ──────────────────────────────────────────────────────────────────────────── +// Import map: bare specifier → resolved URL (populated by __nsConfigureRuntime) +// Instead of rewriting import statements in source code on the Vite side, the runtime +// resolves bare specifiers through this map to either vendor URLs (ns-vendor://) +// or HTTP module URLs. Source code is served as Vite transformed it. +static std::unordered_map g_importMap; + +// Volatile URL patterns: URLs matching these substrings are always re-fetched +// (cache is evicted before loading). Configured by Vite at boot instead of +// being hardcoded. Replaces hardcoded /@ns/sfc/ and __webpack_* checks. +static std::vector g_volatilePatterns; + +// Vendor module registry: maps vendor specifier → evaluated v8::Module. +// Populated when ns-vendor:// modules are first resolved via SyntheticModule. +// Per-isolate (thread_local) for the same reason as g_moduleRegistry above — +// vendor SyntheticModules are isolate-bound and reusing one across isolates +// breaks the linker's export-table check. +namespace { +std::unordered_map>& MakePerIsolateVendorModuleCache() { + thread_local auto* p = new std::unordered_map>(); + return *p; +} +} // namespace + +static thread_local std::unordered_map>& g_vendorModuleCache = MakePerIsolateVendorModuleCache(); + +static bool ShouldTraceRegistryKey(const std::string& rawKey, const std::string& registryKey) { + if (rawKey != registryKey) { + return true; + } + + return StartsWith(registryKey, "ns-vendor://") || StartsWith(registryKey, "optional:") || + StartsWith(registryKey, "node:") || StartsWith(registryKey, "blob:"); +} + +static std::string CanonicalizeRegistryKey(const std::string& key) { + if (key.empty()) { + return key; + } + + std::string registryKey; + const char* classification = "path"; + bool traceEvenWithoutChange = false; + + if (StartsWith(key, "http://") || StartsWith(key, "https://") || + StartsWith(key, "file://http://") || StartsWith(key, "file://https://")) { + registryKey = CanonicalizeHttpUrlKey(key); + classification = "http"; + } else if (StartsWith(key, "file://")) { + registryKey = NormalizePath(FileURLToPath(key)); + classification = "file-url"; + } else if (StartsWith(key, "blob:")) { + registryKey = key; + classification = "blob"; + traceEvenWithoutChange = true; + } else { + // Preserve non-filesystem module namespaces such as ns-vendor://, optional:, + // and node: so synthetic/in-memory modules keep their exact registry identity. + size_t schemePos = key.find(':'); + size_t slashPos = key.find('/'); + if (schemePos != std::string::npos && (slashPos == std::string::npos || schemePos < slashPos)) { + registryKey = key; + classification = "custom-scheme"; + traceEvenWithoutChange = true; + } else { + registryKey = NormalizePath(key); + } + } + + if (IsScriptLoadingLogEnabled() && (traceEvenWithoutChange || registryKey != key)) { + Log(@"[resolver][registry-key][%s] raw=%s key=%s", classification, key.c_str(), + registryKey.c_str()); + } + + return registryKey; +} + +v8::MaybeLocal LoadHttpModuleForUrl(v8::Isolate* isolate, + v8::Local context, + const std::string& requestedUrl) { + const std::string registryKey = CanonicalizeHttpUrlKey(requestedUrl); + + if (IsScriptLoadingLogEnabled()) { + Log(@"[http-esm][load][begin] request=%s key=%s", requestedUrl.c_str(), + registryKey.c_str()); + } + + auto itExisting = g_moduleRegistry.find(registryKey); + if (itExisting != g_moduleRegistry.end()) { + v8::Local existing = itExisting->second.Get(isolate); + if (!existing.IsEmpty() && existing->GetStatus() != v8::Module::kErrored) { + if (IsScriptLoadingLogEnabled()) { + Log(@"[http-esm][load][cache-hit] key=%s", registryKey.c_str()); + } + return v8::MaybeLocal(existing); + } + + if (IsScriptLoadingLogEnabled()) { + Log(@"[http-esm][load][drop-errored] key=%s", registryKey.c_str()); + } + RemoveModuleFromRegistry(registryKey); + } + + std::string body; + std::string contentType; + int status = 0; + if (!HttpFetchText(requestedUrl, body, contentType, status) || body.empty()) { + if (IsScriptLoadingLogEnabled()) { + Log(@"[http-esm][load][fetch-fail] request=%s key=%s status=%d", + requestedUrl.c_str(), registryKey.c_str(), status); + } + if (RuntimeConfig.IsDebug) { + std::string msg = "HTTP import failed: " + requestedUrl + " (status=" + + std::to_string(status) + ")"; + isolate->ThrowException(v8::Exception::Error(tns::ToV8String(isolate, msg.c_str()))); + } + return v8::MaybeLocal(); + } + + v8::MaybeLocal loaded = + CompileModuleForResolveRegisterOnly(isolate, context, body, registryKey); + if (loaded.IsEmpty()) { + if (IsScriptLoadingLogEnabled()) { + Log(@"[http-esm][load][compile-fail] request=%s key=%s bytes=%zu", + requestedUrl.c_str(), registryKey.c_str(), body.size()); + } + if (RuntimeConfig.IsDebug) { + std::string msg = "HTTP import compile failed: " + requestedUrl; + isolate->ThrowException(v8::Exception::Error(tns::ToV8String(isolate, msg.c_str()))); + } + return v8::MaybeLocal(); + } + + if (IsScriptLoadingLogEnabled()) { + Log(@"[http-esm][load][ok] request=%s key=%s type=%s bytes=%zu", + requestedUrl.c_str(), registryKey.c_str(), contentType.c_str(), body.size()); + } + + return loaded; +} + +// ── Import map helpers ────────────────────────────────────────────────────── + +void SetImportMap(const std::string& json) { + g_importMap.clear(); + // Minimal JSON parser for {"imports": {"key": "value", ...}} shape. + // We avoid pulling in a full JSON library; the import map is simple flat key-value. + size_t importsPos = json.find("\"imports\""); + if (importsPos == std::string::npos) return; + size_t braceOpen = json.find('{', importsPos + 9); + if (braceOpen == std::string::npos) return; + size_t braceClose = json.find('}', braceOpen + 1); + if (braceClose == std::string::npos) return; + + std::string inner = json.substr(braceOpen + 1, braceClose - braceOpen - 1); + // Parse "key": "value" pairs + size_t pos = 0; + while (pos < inner.size()) { + size_t keyStart = inner.find('"', pos); + if (keyStart == std::string::npos) break; + size_t keyEnd = inner.find('"', keyStart + 1); + if (keyEnd == std::string::npos) break; + std::string key = inner.substr(keyStart + 1, keyEnd - keyStart - 1); + + size_t valStart = inner.find('"', keyEnd + 1); + if (valStart == std::string::npos) break; + size_t valEnd = inner.find('"', valStart + 1); + if (valEnd == std::string::npos) break; + std::string val = inner.substr(valStart + 1, valEnd - valStart - 1); + + g_importMap[key] = val; + pos = valEnd + 1; + } + if (IsScriptLoadingLogEnabled()) { + Log(@"[import-map] loaded %lu entries", (unsigned long)g_importMap.size()); + } +} + +void SetVolatilePatterns(const std::vector& patterns) { + g_volatilePatterns = patterns; + if (IsScriptLoadingLogEnabled()) { + Log(@"[import-map] volatile patterns: %lu", (unsigned long)g_volatilePatterns.size()); + } +} + +// Check if a URL matches any volatile pattern (should bypass cache). +static bool IsVolatileUrl(const std::string& url) { + for (const auto& pat : g_volatilePatterns) { + if (url.find(pat) != std::string::npos) return true; + } + return false; +} + +// Normalize a Vite-rewritten specifier into the canonical import-map key. +// Handles two common Vite dev-server rewrite patterns: +// 1. Prebundled deps: "/node_modules/.vite/deps/solid-js.js?v=abc" → "solid-js" +// "/node_modules/.vite/deps/@tanstack_solid-router.js" → "@tanstack/solid-router" +// 2. Explicit node_modules paths: +// "/node_modules/@angular/core/fesm2022/core.mjs" → "@angular/core/fesm2022/core.mjs" +// "/node_modules/tslib/tslib.es6.mjs" → "tslib" +// +// For explicit node_modules paths we preserve non-main-entry subpaths so the +// import map's trailing-slash HTTP prefixes can keep complex package build +// outputs on HTTP. Only bare package roots and simple root-level main entries +// collapse back to the package id for vendor/exact import-map resolution. +// Returns the normalized import-map key or empty string if not a node_modules path. +static std::string NormalizeViteSpecifier(const std::string& specifier) { + // Pattern 1: Vite prebundled deps — /node_modules/.vite/deps/.js + { + const std::string viteDepsPrefix = "/node_modules/.vite/deps/"; + // Also handle without leading slash + const std::string viteDepsPrefix2 = "node_modules/.vite/deps/"; + std::string prefix; + if (specifier.compare(0, viteDepsPrefix.size(), viteDepsPrefix) == 0) + prefix = viteDepsPrefix; + else if (specifier.compare(0, viteDepsPrefix2.size(), viteDepsPrefix2) == 0) + prefix = viteDepsPrefix2; + + if (!prefix.empty()) { + std::string id = specifier.substr(prefix.size()); + // Strip extension (.js, .mjs, .cjs) and query params + auto qpos = id.find('?'); + if (qpos != std::string::npos) id = id.substr(0, qpos); + auto dotpos = id.rfind('.'); + if (dotpos != std::string::npos) id = id.substr(0, dotpos); + // Reverse esbuild flattening: first _ after @ is / (scope separator), + // remaining __ are . and _ are / — but we only need the package root. + // Examples: "solid-js" → "solid-js", "@tanstack_solid-router" → "@tanstack/solid-router" + if (!id.empty() && id[0] == '@') { + // Scoped package: find first underscore → scope/name + auto upos = id.find('_'); + if (upos != std::string::npos) { + id = id.substr(0, upos) + "/" + id.substr(upos + 1); + // If there are more underscores, the rest is subpath — just keep scope/name + auto upos2 = id.find('_', upos + 1); + if (upos2 != std::string::npos) { + id = id.substr(0, upos2); + } + } + } + if (IsScriptLoadingLogEnabled()) { + Log(@"[import-map][normalize] vite-deps: %s -> %s", specifier.c_str(), id.c_str()); + } + return id; + } + } + + // Pattern 2: Resolved node_modules path — /node_modules//... + { + const std::string nmPrefix = "/node_modules/"; + const std::string nmPrefix2 = "node_modules/"; + std::string sub; + if (specifier.compare(0, nmPrefix.size(), nmPrefix) == 0) + sub = specifier.substr(nmPrefix.size()); + else if (specifier.compare(0, nmPrefix2.size(), nmPrefix2) == 0) + sub = specifier.substr(nmPrefix2.size()); + + if (!sub.empty() && sub[0] != '.') { + // Skip .vite/ paths (handled above) + if (sub.compare(0, 6, ".vite/") == 0) return ""; + + std::string subNoQuery = sub; + std::string querySuffix; + auto subQueryPos = sub.find('?'); + if (subQueryPos != std::string::npos) { + subNoQuery = sub.substr(0, subQueryPos); + querySuffix = sub.substr(subQueryPos); + } + + // Extract package name: @scope/name or name + std::string pkgName; + if (subNoQuery[0] == '@') { + // Scoped: @scope/name + auto slash1 = subNoQuery.find('/'); + if (slash1 != std::string::npos) { + auto slash2 = subNoQuery.find('/', slash1 + 1); + pkgName = (slash2 != std::string::npos) ? subNoQuery.substr(0, slash2) : subNoQuery; + } + } else { + // Unscoped: name + auto slash = subNoQuery.find('/'); + pkgName = (slash != std::string::npos) ? subNoQuery.substr(0, slash) : subNoQuery; + } + if (!pkgName.empty()) { + std::string normalized = pkgName; + std::string remainder; + if (subNoQuery.size() > pkgName.size()) { + remainder = subNoQuery.substr(pkgName.size()); + if (!remainder.empty() && remainder[0] == '/') { + remainder.erase(0, 1); + } + } + + if (!remainder.empty()) { + bool preserveSubpath = remainder.find('/') != std::string::npos; + + if (!preserveSubpath) { + const std::string pkgBaseName = pkgName.substr(pkgName.find_last_of('/') + 1); + std::string withoutExt = remainder; + auto dot = withoutExt.rfind('.'); + if (dot != std::string::npos) { + withoutExt = withoutExt.substr(0, dot); + } + std::string withoutPlatform = withoutExt; + for (const auto& suffix : {std::string(".ios"), std::string(".android"), std::string(".visionos")}) { + if (EndsWith(withoutPlatform, suffix)) { + withoutPlatform = withoutPlatform.substr(0, withoutPlatform.size() - suffix.size()); + break; + } + } + const bool isRootLevelMainEntry = withoutPlatform == "index" || + withoutPlatform == pkgBaseName || + withoutPlatform.rfind(pkgBaseName + ".", 0) == 0; + preserveSubpath = !isRootLevelMainEntry; + } + + if (preserveSubpath) { + normalized = pkgName + "/" + remainder + querySuffix; + } + } + + if (IsScriptLoadingLogEnabled()) { + Log(@"[import-map][normalize] node_modules: %s -> %s", specifier.c_str(), normalized.c_str()); + } + return normalized; + } + } + } + + return ""; +} + +// Look up a specifier in the import map. Supports both exact matches and +// prefix matches (trailing-slash entries like "solid-js/" that map subpaths). +// Returns the mapped URL or empty string if no match. +static std::string LookupImportMap(const std::string& specifier) { + // 1. Exact match + auto it = g_importMap.find(specifier); + if (it != g_importMap.end()) { + if (IsScriptLoadingLogEnabled()) { + Log(@"[import-map] exact: %s -> %s", specifier.c_str(), it->second.c_str()); + } + return it->second; + } + // 2. Prefix match (longest match wins) + std::string bestKey; + std::string bestValue; + for (const auto& kv : g_importMap) { + const std::string& key = kv.first; + // Prefix entries must end with '/' + if (key.back() != '/') continue; + if (specifier.size() > key.size() && specifier.compare(0, key.size(), key) == 0) { + if (key.size() > bestKey.size()) { + bestKey = key; + bestValue = kv.second; + } + } + } + if (!bestKey.empty()) { + std::string remainder = specifier.substr(bestKey.size()); + std::string resolved = bestValue + remainder; + if (IsScriptLoadingLogEnabled()) { + Log(@"[import-map] prefix: %s -> %s (via %s)", specifier.c_str(), resolved.c_str(), bestKey.c_str()); + } + return resolved; + } + return ""; +} + +// Escape `s` as a single-quoted JS string literal. Returns the literal +// including the surrounding quotes so call sites can splice it directly +// into a generated source string (e.g. `"foo(" + JsStringLiteral(id) + ")"`). +// Handles backslash, single quote, the JS line terminators (\n, \r, +// U+2028, U+2029), and other ASCII control characters via `\xNN`. +static std::string JsStringLiteral(const std::string& s) { + std::string out; + out.reserve(s.size() + 2); + out.push_back('\''); + for (size_t i = 0; i < s.size(); ) { + unsigned char c = static_cast(s[i]); + if (c == '\\') { out += "\\\\"; ++i; continue; } + if (c == '\'') { out += "\\'"; ++i; continue; } + if (c == '\n') { out += "\\n"; ++i; continue; } + if (c == '\r') { out += "\\r"; ++i; continue; } + if (c == 0xE2 && i + 2 < s.size() && + static_cast(s[i + 1]) == 0x80 && + (static_cast(s[i + 2]) == 0xA8 || + static_cast(s[i + 2]) == 0xA9)) { + out += (static_cast(s[i + 2]) == 0xA8) ? "\\u2028" : "\\u2029"; + i += 3; + continue; + } + if (c < 0x20) { + char buf[7]; + std::snprintf(buf, sizeof(buf), "\\x%02X", c); + out += buf; + ++i; + continue; + } + out.push_back(static_cast(c)); + ++i; + } + out.push_back('\''); + return out; +} + +// Helper: returns true if `name` is a valid JS identifier that can appear in +// `export const = ...` without quoting. Conservative check — rejects +// anything that could cause a parse error in the generated ESM wrapper. +static bool IsValidJSIdentifier(const std::string& name) { + if (name.empty()) return false; + char first = name[0]; + // Must start with letter, underscore, or $ + if (!((first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') || + first == '_' || first == '$')) + return false; + for (size_t i = 1; i < name.size(); i++) { + char c = name[i]; + if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '_' || c == '$')) + return false; + } + return true; +} + +// Create an ESM wrapper that re-exports all named exports from the vendor registry. +// The vendor bootstrap (JS side) populates globalThis.__nsVendorRegistry with +// pre-bundled module namespace objects (via `import * as`). This function enumerates +// the actual property names of the vendor module and generates explicit +// `export const X = __mod['X'];` statements so V8's ESM resolution finds every +// named export (e.g. $DEVCOMP, createSignal, createRootRoute, etc.). +static v8::MaybeLocal ResolveFromVendorRegistry(v8::Isolate* isolate, + v8::Local context, + const std::string& vendorId) { + // Check cache first + auto cached = g_vendorModuleCache.find(vendorId); + if (cached != g_vendorModuleCache.end()) { + v8::Local mod = cached->second.Get(isolate); + if (!mod.IsEmpty() && mod->GetStatus() != v8::Module::kErrored) { + return mod; + } + cached->second.Reset(); + g_vendorModuleCache.erase(cached); + } + + // ── Step 1: Enumerate export names from the live vendor module ────────── + // Access globalThis.__nsVendorRegistry (a Map) and call .get(vendorId) + // to obtain the namespace object, then read its property names. + std::vector exportNames; + + v8::TryCatch tc(isolate); + do { + v8::Local global = context->Global(); + + // globalThis.__nsVendorRegistry + v8::Local regVal; + if (!global->Get(context, tns::ToV8String(isolate, "__nsVendorRegistry")).ToLocal(®Val) || + regVal->IsNullOrUndefined()) { + break; + } + v8::Local registry = regVal.As(); + + // registry.get(vendorId) + v8::Local getFnVal; + if (!registry->Get(context, tns::ToV8String(isolate, "get")).ToLocal(&getFnVal) || + !getFnVal->IsFunction()) { + break; + } + v8::Local getArgs[] = { tns::ToV8String(isolate, vendorId.c_str()) }; + v8::Local modVal; + if (!getFnVal.As()->Call(context, registry, 1, getArgs).ToLocal(&modVal) || + modVal->IsNullOrUndefined()) { + break; + } + + // Object.keys(mod) — enumerate own property names + v8::Local modObj = modVal.As(); + v8::Local keys; + if (!modObj->GetOwnPropertyNames(context).ToLocal(&keys)) { + break; + } + + for (uint32_t i = 0; i < keys->Length(); i++) { + v8::Local key; + if (!keys->Get(context, i).ToLocal(&key) || !key->IsString()) continue; + v8::String::Utf8Value keyUtf8(isolate, key); + if (!*keyUtf8) continue; + std::string name(*keyUtf8); + if (name != "default" && IsValidJSIdentifier(name)) { + exportNames.push_back(name); + } + } + } while (false); + + if (tc.HasCaught()) { + tc.Reset(); // Non-fatal; we'll fall back to no named exports + } + + // ── Step 2: Generate ESM wrapper with explicit named exports ──────────── + std::string moduleKey = "ns-vendor://" + vendorId; + // Two failure modes are distinguished so the runtime error names the + // class of problem: registry not yet populated (wrapper evaluated + // before `installVendorBootstrap()` ran) vs. specifier absent from a + // populated registry (vendor bundle does not ship this entry). + // `vendorId` is escaped through `JsStringLiteral` so any character is + // safe to embed inside the generated JS source. + const std::string idLiteral = JsStringLiteral(vendorId); + std::string src = + "const __reg = globalThis.__nsVendorRegistry;\n" + "if (!__reg || __reg.size === 0) {\n" + " throw new Error('ns-vendor wrapper ' + " + idLiteral + + " + ' evaluated before __nsVendorRegistry was populated');\n" + "}\n" + "const __mod = __reg.get(" + idLiteral + ");\n" + "if (!__mod) {\n" + " throw new Error('ns-vendor specifier ' + " + idLiteral + + " + ' not in __nsVendorRegistry (' + __reg.size + ' entries)');\n" + "}\n" + "export default __mod.default !== undefined ? __mod.default : __mod;\n"; + + for (const auto& name : exportNames) { + src += "export const " + name + " = __mod[" + JsStringLiteral(name) + "];\n"; + } + + if (IsScriptLoadingLogEnabled()) { + Log(@"[import-map][vendor] generating wrapper for ns-vendor://%s with %lu named exports", + vendorId.c_str(), (unsigned long)exportNames.size()); + } + + v8::MaybeLocal m = CompileModuleForResolveRegisterOnly(isolate, context, src, moduleKey); + if (!m.IsEmpty()) { + v8::Local mod; + if (m.ToLocal(&mod)) { + g_vendorModuleCache[vendorId].Reset(isolate, mod); + if (IsScriptLoadingLogEnabled()) { + Log(@"[import-map][vendor] resolved ns-vendor://%s", vendorId.c_str()); + } + } + } + return m; +} + +void CleanupImportMapGlobals() { + g_importMap.clear(); + g_volatilePatterns.clear(); + for (auto& kv : g_vendorModuleCache) { kv.second.Reset(); } + g_vendorModuleCache.clear(); + // Also clear fallback registries — they hold v8::Global handles that + // would crash during static destructor cleanup if not cleared before isolate disposal. + for (auto& kv : g_moduleFallbackRegistry) { kv.second.Reset(); } + g_moduleFallbackRegistry.clear(); + for (auto& kv : g_moduleFallbackByRelative) { kv.second.Reset(); } + g_moduleFallbackByRelative.clear(); +} + // g_modulesInFlight is defined later in this translation unit (thread_local static); no extern needed here. static bool IsDocumentsPath(const std::string& path); static std::vector DocumentsPathAliases(const std::string& path); static std::string ExtractRelativePath(const std::string& path); +static void RejectAndClearInvalidatedModuleState(v8::Isolate* isolate, + v8::Local context, + const std::string& registryKey); // Returns the normalized iOS Documents directory (cached). Empty string if unavailable. static const std::string& GetDocumentsDirectory() { @@ -282,6 +849,7 @@ static inline bool StartsWith(const std::string& s, const char* prefix) { } void RemoveModuleFromRegistry(const std::string& canonicalPath) { + const std::string registryKey = CanonicalizeRegistryKey(canonicalPath); // Defensive: never operate on an anomalous/sentinel key. // This covers the bare "@" anomaly and the special invalid-at stub module used by the dev HTTP loader. auto isSentinel = [](const std::string& s) -> bool { @@ -289,9 +857,9 @@ void RemoveModuleFromRegistry(const std::string& canonicalPath) { // Match any path or URL that includes the invalid-at stub filename return s.find("__invalid_at__.mjs") != std::string::npos; }; - if (isSentinel(canonicalPath)) { + if (isSentinel(registryKey)) { if (IsScriptLoadingLogEnabled()) { - Log(@"[resolver][guard-v3] ignore remove for sentinel %s", canonicalPath.c_str()); + Log(@"[resolver][guard-v3] ignore remove for sentinel %s", registryKey.c_str()); } return; } @@ -302,6 +870,7 @@ void RemoveModuleFromRegistry(const std::string& canonicalPath) { if (s.find("__invalid_at__.mjs") != std::string::npos) return "sentinel:invalid_at"; bool http = StartsWith(s, "http://") || StartsWith(s, "https://"); if (http) { + if (IsVolatileUrl(s)) return "http:volatile"; if (s.find("/@ns/sfc/") != std::string::npos) return "http:sfc"; if (s.find("/@ns/m/") != std::string::npos) return "http:m"; return "http:other"; @@ -311,33 +880,37 @@ void RemoveModuleFromRegistry(const std::string& canonicalPath) { }; if (IsScriptLoadingLogEnabled()) { - Log(@"[resolver][remove:pre] key=%s class=%s", canonicalPath.c_str(), classify(canonicalPath)); + if (registryKey != canonicalPath) { + Log(@"[resolver][remove:pre] raw=%s key=%s class=%s", canonicalPath.c_str(), registryKey.c_str(), classify(registryKey)); + } else { + Log(@"[resolver][remove:pre] key=%s class=%s", registryKey.c_str(), classify(registryKey)); + } } size_t regPre = g_moduleRegistry.size(); size_t fbPre = g_moduleFallbackRegistry.size(); size_t relPre = g_moduleFallbackByRelative.size(); - auto it = g_moduleRegistry.find(canonicalPath); + auto it = g_moduleRegistry.find(registryKey); if (it != g_moduleRegistry.end()) { // Only log stale removal for non-HTTP keys to avoid noisy dev HTTP churn. - bool isHttpKey = StartsWith(canonicalPath, "http://") || StartsWith(canonicalPath, "https://"); + bool isHttpKey = StartsWith(registryKey, "http://") || StartsWith(registryKey, "https://"); if (IsScriptLoadingLogEnabled() && !isHttpKey) { - Log(@"[resolver] removing stale module %@", [NSString stringWithUTF8String:canonicalPath.c_str()]); + Log(@"[resolver] removing stale module %@", [NSString stringWithUTF8String:registryKey.c_str()]); } it->second.Reset(); g_moduleRegistry.erase(it); } else if (IsScriptLoadingLogEnabled()) { - Log(@"[resolver][remove:miss] key not found, proceed to clear fallbacks (%s)", canonicalPath.c_str()); + Log(@"[resolver][remove:miss] key not found, proceed to clear fallbacks (%s)", registryKey.c_str()); } // Also clear fallbacks linked to this path - auto fb = g_moduleFallbackRegistry.find(canonicalPath); + auto fb = g_moduleFallbackRegistry.find(registryKey); if (fb != g_moduleFallbackRegistry.end()) { fb->second.Reset(); g_moduleFallbackRegistry.erase(fb); } - std::string rel = ExtractRelativePath(canonicalPath); + std::string rel = ExtractRelativePath(registryKey); if (!rel.empty()) { auto fbr = g_moduleFallbackByRelative.find(rel); if (fbr != g_moduleFallbackByRelative.end()) { @@ -357,6 +930,80 @@ void RemoveModuleFromRegistry(const std::string& canonicalPath) { } } +std::vector GetLoadedModuleUrls() { + std::vector urls; + urls.reserve(g_moduleRegistry.size()); + + for (const auto& entry : g_moduleRegistry) { + const std::string& key = entry.first; + if (key.empty()) continue; + if (StartsWith(key, "blob:") || key.find("://") != std::string::npos) { + urls.push_back(key); + } + } + + std::sort(urls.begin(), urls.end()); + urls.erase(std::unique(urls.begin(), urls.end()), urls.end()); + return urls; +} + +void InvalidateModules(v8::Isolate* isolate, v8::Local context, + const std::vector& urls) { + if (urls.empty()) return; + + std::unordered_set seen; + std::vector uniqueUrls; + uniqueUrls.reserve(urls.size()); + + for (const auto& url : urls) { + if (url.empty()) continue; + std::string registryKey = CanonicalizeRegistryKey(url); + if (registryKey.empty()) continue; + if (!seen.insert(registryKey).second) continue; + uniqueUrls.push_back(registryKey); + } + + const bool logScriptLoading = IsScriptLoadingLogEnabled(); + size_t hits = 0, misses = 0; + for (const auto& url : uniqueUrls) { + bool present = g_moduleRegistry.find(url) != g_moduleRegistry.end(); + if (present) { + hits++; + } else { + misses++; + } + if (logScriptLoading) { + Log(@"[ns-hmr][ios-invalidate] %s key=%s", + present ? "HIT " : "MISS", + url.c_str()); + } + + RejectAndClearInvalidatedModuleState(isolate, context, url); + RemoveModuleFromRegistry(url); + } + + // Drop stale HTTP bodies from the speculative-prefetch cache for + // every URL we just invalidated. Without this, the next + // `HttpFetchText` for an evicted URL would happily return a stale + // body the previous wave (or kickstart) left in the cache, and V8 + // would compile that stale source — producing the "1 cycle behind" + // lag for `.ts` edits with many transitive importers (e.g. + // constants files). The registry eviction above alone is necessary + // but not sufficient: V8 calls into the loader for any module no + // longer in the registry, and the loader's first stop is + // `g_prefetchCache`. Both caches must be cleared for the next + // compile to see fresh source. + EvictHttpModulePrefetchCacheUrls(uniqueUrls); + + if (logScriptLoading) { + Log(@"[ns-hmr][ios-invalidate] summary unique=%lu hits=%lu misses=%lu (registry now=%lu)", + (unsigned long)uniqueUrls.size(), + (unsigned long)hits, + (unsigned long)misses, + (unsigned long)g_moduleRegistry.size()); + } +} + void UpdateModuleFallback(v8::Isolate* isolate, const std::string& canonicalPath, v8::Local module) { auto fallbackIt = g_moduleFallbackRegistry.find(canonicalPath); @@ -430,6 +1077,204 @@ void UpdateModuleFallback(v8::Isolate* isolate, const std::string& canonicalPath // Dynamic HTTP import waiters: resolve to module namespace when available. static thread_local std::unordered_map>> g_httpDynamicWaiters; +static bool IsModuleEvaluationInProgress(v8::Module::Status status) { + return status == v8::Module::kInstantiating || status == v8::Module::kEvaluating; +} + +static void ResolveResolversWithModuleNamespace( + v8::Isolate* isolate, v8::Local context, + std::vector>& resolvers, + v8::Local module, const std::string& registryKey) { + if (resolvers.empty()) { + return; + } + + if (module.IsEmpty() || module->GetStatus() != v8::Module::kEvaluated) { + v8::Local errMsg = tns::ToV8String( + isolate, ("Module did not finish evaluation: " + registryKey).c_str()); + v8::Local errObj = v8::Exception::Error(errMsg); + for (auto& resGlobal : resolvers) { + v8::Local resolver = resGlobal.Get(isolate); + if (!resolver.IsEmpty()) { + resolver->Reject(context, errObj).FromMaybe(false); + } + resGlobal.Reset(); + } + return; + } + + v8::Local moduleNamespace = module->GetModuleNamespace(); + for (auto& resGlobal : resolvers) { + v8::Local resolver = resGlobal.Get(isolate); + if (!resolver.IsEmpty()) { + resolver->Resolve(context, moduleNamespace).FromMaybe(false); + } + resGlobal.Reset(); + } +} + +static void RejectResolversWithReason( + v8::Isolate* isolate, v8::Local context, + std::vector>& resolvers, + v8::Local reason) { + (void)isolate; + if (resolvers.empty()) { + return; + } + + for (auto& resGlobal : resolvers) { + v8::Local resolver = resGlobal.Get(isolate); + if (!resolver.IsEmpty()) { + resolver->Reject(context, reason).FromMaybe(false); + } + resGlobal.Reset(); + } +} + +static bool QueueModuleWaiterIfInFlight( + v8::Isolate* isolate, const std::string& registryKey, + v8::Local module, v8::Local resolver) { + if (registryKey.empty() || module.IsEmpty() || + !IsModuleEvaluationInProgress(module->GetStatus()) || + g_modulesInFlight.find(registryKey) == g_modulesInFlight.end()) { + return false; + } + + g_moduleWaiters[registryKey].emplace_back(isolate, resolver); + if (IsScriptLoadingLogEnabled()) { + Log(@"[dyn-import][await] queued module waiter for %s status=%s", registryKey.c_str(), + ModuleStatusToString(module->GetStatus())); + } + return true; +} + +static bool QueueHttpDynamicWaiterIfInFlight( + v8::Isolate* isolate, const std::string& registryKey, + v8::Local module, v8::Local resolver) { + if (registryKey.empty() || module.IsEmpty() || + !IsModuleEvaluationInProgress(module->GetStatus()) || + g_modulesInFlight.find(registryKey) == g_modulesInFlight.end()) { + return false; + } + + g_httpDynamicWaiters[registryKey].emplace_back(isolate, resolver); + if (IsScriptLoadingLogEnabled()) { + Log(@"[dyn-import][http-await] queued waiter for %s status=%s", + registryKey.c_str(), ModuleStatusToString(module->GetStatus())); + } + return true; +} + +static void ResolveModuleWaiters(v8::Isolate* isolate, + v8::Local context, + const std::string& registryKey, + v8::Local module) { + auto waitIt = g_moduleWaiters.find(registryKey); + if (waitIt == g_moduleWaiters.end()) { + return; + } + + std::vector> resolvers; + resolvers.swap(waitIt->second); + g_moduleWaiters.erase(waitIt); + ResolveResolversWithModuleNamespace(isolate, context, resolvers, module, registryKey); +} + +static void RejectModuleWaiters(v8::Isolate* isolate, + v8::Local context, + const std::string& registryKey, + v8::Local reason) { + auto waitIt = g_moduleWaiters.find(registryKey); + if (waitIt == g_moduleWaiters.end()) { + return; + } + + std::vector> resolvers; + resolvers.swap(waitIt->second); + g_moduleWaiters.erase(waitIt); + RejectResolversWithReason(isolate, context, resolvers, reason); +} + +static void ResolveHttpDynamicWaiters(v8::Isolate* isolate, + v8::Local context, + const std::string& registryKey, + v8::Local module) { + auto waitIt = g_httpDynamicWaiters.find(registryKey); + if (waitIt != g_httpDynamicWaiters.end()) { + std::vector> resolvers; + resolvers.swap(waitIt->second); + g_httpDynamicWaiters.erase(waitIt); + ResolveResolversWithModuleNamespace(isolate, context, resolvers, module, registryKey); + } + + g_modulesInFlight.erase(registryKey); +} + +static void RejectHttpDynamicWaiters(v8::Isolate* isolate, + v8::Local context, + const std::string& registryKey, + v8::Local reason) { + auto waitIt = g_httpDynamicWaiters.find(registryKey); + if (waitIt != g_httpDynamicWaiters.end()) { + std::vector> resolvers; + resolvers.swap(waitIt->second); + g_httpDynamicWaiters.erase(waitIt); + RejectResolversWithReason(isolate, context, resolvers, reason); + } + + g_modulesInFlight.erase(registryKey); +} + +static void RejectResolversForInvalidation( + v8::Isolate* isolate, v8::Local context, + std::vector>& resolvers, + const std::string& registryKey) { + if (resolvers.empty()) { + return; + } + + std::string message = "Module invalidated during dev reload: " + registryKey; + v8::Local error = v8::Exception::Error(tns::ToV8String(isolate, message.c_str())); + for (auto& resolverGlobal : resolvers) { + v8::Local resolver = resolverGlobal.Get(isolate); + if (!resolver.IsEmpty()) { + resolver->Reject(context, error).FromMaybe(false); + } + resolverGlobal.Reset(); + } +} + +static void RejectAndClearInvalidatedModuleState(v8::Isolate* isolate, + v8::Local context, + const std::string& registryKey) { + g_moduleReentryCounts.erase(registryKey); + g_moduleReentryParents.erase(registryKey); + g_modulePrimaryImporters.erase(registryKey); + g_modulesInFlight.erase(registryKey); + g_modulesPendingReset.erase(registryKey); + + auto waitIt = g_moduleWaiters.find(registryKey); + if (waitIt != g_moduleWaiters.end()) { + std::vector> resolvers; + resolvers.swap(waitIt->second); + g_moduleWaiters.erase(waitIt); + RejectResolversForInvalidation(isolate, context, resolvers, registryKey); + } + + auto dynamicWaitIt = g_httpDynamicWaiters.find(registryKey); + if (dynamicWaitIt != g_httpDynamicWaiters.end()) { + std::vector> resolvers; + resolvers.swap(dynamicWaitIt->second); + g_httpDynamicWaiters.erase(dynamicWaitIt); + RejectResolversForInvalidation(isolate, context, resolvers, registryKey); + } + + if (IsScriptLoadingLogEnabled()) { + Log(@"[resolver][invalidate-state] cleared in-flight state for %s", + registryKey.c_str()); + } +} + // Bulk await state + callbacks (non-capturing for V8 function compatibility) struct BulkWaitState { size_t remaining; @@ -437,6 +1282,8 @@ void UpdateModuleFallback(v8::Isolate* isolate, const std::string& canonicalPath v8::Global master; }; +// Clear waiter maps that hold v8::Global handles. +// Called from CleanupModuleWaiters() which is invoked by the Runtime destructor. static bool IsDocumentsPath(const std::string& path) { if (path.empty()) return false; const std::string& docs = GetDocumentsDirectory(); @@ -571,28 +1418,19 @@ static bool IsDocumentsPath(const std::string& path) { bool isError = finalStatus == v8::Module::kErrored; auto waitIt = g_moduleWaiters.find(entry_); if (waitIt != g_moduleWaiters.end()) { - std::vector> resolvers; - resolvers.swap(waitIt->second); - g_moduleWaiters.erase(waitIt); if (IsScriptLoadingLogEnabled()) { - Log(@"[resolver][await] resolving %lu waiter(s) for %s status=%s", - (unsigned long)resolvers.size(), entry_.c_str(), ModuleStatusToString(finalStatus)); - } - for (auto &resGlobal : resolvers) { - v8::Local r = resGlobal.Get(isolate_); - if (!r.IsEmpty()) { - if (isError) { - v8::Local errMsg = tns::ToV8String(isolate_, ("Module evaluation failed: " + entry_).c_str()); - v8::Local errObj = v8::Exception::Error(errMsg); - r->Reject(isolate_->GetCurrentContext(), errObj).FromMaybe(false); - } else { - if (IsScriptLoadingLogEnabled()) { - Log(@"[resolver][await] module now evaluated; fulfilling waiter: %s", entry_.c_str()); - } - r->Resolve(isolate_->GetCurrentContext(), v8::Undefined(isolate_)).FromMaybe(false); - } - } - resGlobal.Reset(); + Log(@"[resolver][await] settling waiter(s) for %s status=%s", + entry_.c_str(), ModuleStatusToString(finalStatus)); + } + v8::Local currentContext = isolate_->GetCurrentContext(); + if (isError || regIt == g_moduleRegistry.end()) { + v8::Local errMsg = tns::ToV8String( + isolate_, ("Module evaluation failed: " + entry_).c_str()); + RejectModuleWaiters(isolate_, currentContext, entry_, + v8::Exception::Error(errMsg)); + } else { + v8::Local resolvedModule = regIt->second.Get(isolate_); + ResolveModuleWaiters(isolate_, currentContext, entry_, resolvedModule); } } stack_.pop_back(); @@ -651,7 +1489,7 @@ static bool IsDocumentsPath(const std::string& path) { } else if (fallbackIt != g_moduleFallbackRegistry.end()) { v8::Local fallback = fallbackIt->second.Get(isolate_); if (!fallback.IsEmpty()) { - g_moduleRegistry[entry_].Reset(isolate_, fallback); + g_moduleRegistry[CanonicalizeRegistryKey(entry_)].Reset(isolate_, fallback); if (IsScriptLoadingLogEnabled()) { Log(@"[resolver] restored fallback module for %s after in-flight reload failed", entry_.c_str()); @@ -674,6 +1512,92 @@ static bool IsDocumentsPath(const std::string& path) { } // namespace +// Compile a `.json` file as a synthetic ES module whose default export is +// the parsed JSON value. Handles registry insertion, eager evaluation, and +// the dual debug-vs-release error reporting that the rest of +// `ResolveModuleCallback` uses. +// +// Behaviour-preserving extraction from the inline `.json` branch in +// `ResolveModuleCallback` — keeps the calling site small enough to read +// the resolver's main flow without scrolling past 70 lines of JSON-only +// concerns. +static v8::MaybeLocal CompileJsonAsEsModule( + v8::Isolate* isolate, v8::Local context, + const std::string& absPath, const std::string& registryAbsPath, + bool isWorker) { + // Debug: Log JSON module handling for worker context + if (isWorker) { + printf("ResolveModuleCallback: Worker handling JSON module '%s'\n", absPath.c_str()); + } + + // Read file contents + std::string jsonText = tns::ReadText(absPath); + + // Debug: Log JSON content preview for worker context + if (isWorker) { + std::string preview = jsonText.length() > 200 ? jsonText.substr(0, 200) + "..." : jsonText; + printf("ResolveModuleCallback: Worker JSON content preview: %s\n", preview.c_str()); + } + + // Build a small ES module that just exports the parsed JSON as default + std::string moduleSource = "export default " + jsonText + ";"; + + v8::Local sourceText = tns::ToV8String(isolate, moduleSource); + // Build URL for stack traces + std::string base = ReplaceAll(absPath, RuntimeConfig.BaseDir, ""); + std::string url = "file://" + base; + + v8::Local urlString; + if (!v8::String::NewFromUtf8(isolate, url.c_str(), v8::NewStringType::kNormal) + .ToLocal(&urlString)) { + if (RuntimeConfig.IsDebug) { + Log(@"Debug mode - Failed to create URL string for JSON module"); + return v8::MaybeLocal(); + } else { + isolate->ThrowException(v8::Exception::Error( + tns::ToV8String(isolate, "Failed to create URL string for JSON module"))); + return v8::MaybeLocal(); + } + } + + v8::ScriptOrigin origin(isolate, urlString, 0, 0, false, -1, v8::Local(), false, + false, true /* is_module */); + + v8::ScriptCompiler::Source src(sourceText, origin); + + v8::Local jsonModule; + if (!v8::ScriptCompiler::CompileModule(isolate, &src).ToLocal(&jsonModule)) { + if (RuntimeConfig.IsDebug) { + Log(@"Debug mode - Failed to compile JSON module"); + return v8::MaybeLocal(); + } else { + isolate->ThrowException( + v8::Exception::SyntaxError(tns::ToV8String(isolate, "Failed to compile JSON module"))); + return v8::MaybeLocal(); + } + } + + // No imports inside this module, so instantiate directly + if (!jsonModule->InstantiateModule(context, &ResolveModuleCallback).FromMaybe(false)) { + return v8::MaybeLocal(); + } + + // Evaluate immediately so namespace is populated + v8::MaybeLocal evalResult = jsonModule->Evaluate(context); + if (evalResult.IsEmpty()) { + return v8::MaybeLocal(); + } + + // Store in registry and return - with safe Global handle management + auto it = g_moduleRegistry.find(registryAbsPath); + if (it != g_moduleRegistry.end()) { + // Clear the existing Global handle before replacing it + it->second.Reset(); + } + g_moduleRegistry[registryAbsPath].Reset(isolate, jsonModule); + return v8::MaybeLocal(jsonModule); +} + // Callback invoked by V8 to resolve `import X from 'specifier';` v8::MaybeLocal ResolveModuleCallback(v8::Local context, v8::Local specifier, @@ -721,64 +1645,87 @@ static bool IsDocumentsPath(const std::string& path) { const std::string& spec = normalizedSpec; // use normalized spec for the rest of the resolution logic - // ── Early absolute-HTTP fast path ───────────────────────────── - // If the specifier itself is an absolute HTTP(S) URL, resolve it immediately via - // the HTTP loader and return before any filesystem candidate logic runs. - // Security: HttpFetchText gates remote module access centrally. - if (StartsWith(spec, "http://") || StartsWith(spec, "https://")) { - std::string key = CanonicalizeHttpUrlKey(spec); - // Added instrumentation for unified phase logging - Log(@"[http-esm][compile][begin] %s", key.c_str()); - // Reuse compiled module if present and healthy - auto itExisting = g_moduleRegistry.find(key); - if (itExisting != g_moduleRegistry.end()) { - v8::Local existing = itExisting->second.Get(isolate); - if (!existing.IsEmpty()) { - v8::Module::Status st = existing->GetStatus(); - if (st == v8::Module::kErrored) { - if (IsScriptLoadingLogEnabled()) { - Log(@"[resolver][http-cache] dropping errored %s", key.c_str()); - } - RemoveModuleFromRegistry(key); - } else { - if (IsScriptLoadingLogEnabled()) { - Log(@"[http-esm][compile][cache-hit] %s", key.c_str()); - } - return v8::MaybeLocal(existing); - } - } - } - std::string body; std::string ct; int status = 0; - if (HttpFetchText(spec, body, ct, status) && !body.empty()) { - v8::MaybeLocal m = CompileModuleForResolveRegisterOnly(isolate, context, body, key); - if (!m.IsEmpty()) { - v8::Local mod; - if (m.ToLocal(&mod)) { - if (IsScriptLoadingLogEnabled()) { - Log(@"[http-esm][compile][ok] %s bytes=%zu", key.c_str(), body.size()); - } - return m; + // Import map resolution + // If the import map is populated (set by __nsConfigureRuntime), check it + // before any other resolution. This is the highest-leverage change from + // the HMR architecture review: bare specifiers resolve through the map + // to either vendor URLs or HTTP module URLs, eliminating the need for + // Vite-side import rewriting. + // + // Specifier normalization. Vite rewrites bare specifiers to + // resolved paths (e.g. "solid-js" → "/node_modules/.vite/deps/solid-js.js"). + // We normalize these back to bare package names so the import map can match + // them. This ensures a SINGLE instance of every package — no matter how + // Vite rewrites the import, the import map resolves to the canonical source. + if (!g_importMap.empty()) { + std::string mapped = LookupImportMap(spec); + + // If direct lookup failed, try normalizing Vite-rewritten specifiers + // back to bare package names and look up again. + if (mapped.empty()) { + std::string normalized = NormalizeViteSpecifier(spec); + if (!normalized.empty()) { + mapped = LookupImportMap(normalized); + if (!mapped.empty() && IsScriptLoadingLogEnabled()) { + Log(@"[resolver][import-map] normalized: %s -> %s -> %s", + spec.c_str(), normalized.c_str(), mapped.c_str()); + } + } + } + + if (!mapped.empty()) { + if (StartsWith(mapped, "ns-vendor://")) { + // Resolve from in-memory vendor registry (already evaluated by vendor bootstrap) + std::string vendorId = mapped.substr(12); // strip "ns-vendor://" + if (IsScriptLoadingLogEnabled()) { + Log(@"[resolver][import-map] vendor: %s -> %s", spec.c_str(), vendorId.c_str()); } + return ResolveFromVendorRegistry(isolate, context, vendorId); } - if (IsScriptLoadingLogEnabled()) { - Log(@"[http-esm][compile][fail][unknown] %s bytes=%zu", key.c_str(), body.size()); - } - if (RuntimeConfig.IsDebug) { - std::string msg = "HTTP import compile failed: " + spec; - isolate->ThrowException(v8::Exception::Error(tns::ToV8String(isolate, msg.c_str()))); - return v8::MaybeLocal(); + // Otherwise it mapped to an HTTP URL or other specifier — update spec + // and fall through to existing resolution (HTTP fast path will pick it up) + normalizedSpec = mapped; + if (IsScriptLoadingLogEnabled()) { + Log(@"[resolver][import-map] rewrite: %s -> %s", spec.c_str(), mapped.c_str()); } } else { - if (RuntimeConfig.IsDebug) { - std::string msg = "HTTP import failed: " + spec + " (status=" + std::to_string(status) + ")"; - isolate->ThrowException(v8::Exception::Error(tns::ToV8String(isolate, msg.c_str()))); - return v8::MaybeLocal(); - } - if (IsScriptLoadingLogEnabled()) { - Log(@"[http-esm][compile][fail][network] %s status=%d", key.c_str(), status); - } + // Diagnostic: bare-looking specifier (no scheme, no '/' prefix, not a + // relative path) that the import map didn't match. + // If we hit this path, the runtime is about to fall back + // to filesystem resolution and almost certainly fail with + // `Cannot find module ...` for vendor packages — surface it loudly + // so a missing import map entry shows up in the dev terminal + // BEFORE the more cryptic `Cannot find module` follow-on. + bool looksBare = !spec.empty() && spec[0] != '/' && spec[0] != '.' && + spec.find("://") == std::string::npos && + spec.find('\\') == std::string::npos; + if (looksBare && IsScriptLoadingLogEnabled()) { + // Snapshot a few entry counts so we can tell at a glance whether + // `g_importMap` is intact (typical: 200-500 entries) or empty. + Log(@"[resolver][import-map][miss] bare='%s' importMap.size=%lu importMap.empty=%d", + spec.c_str(), + (unsigned long)g_importMap.size(), + g_importMap.empty() ? 1 : 0); + } + } + } else if (IsScriptLoadingLogEnabled()) { + // Map was completely empty — distinct from "map populated but no entry". + // This branch firing means `SetImportMap("")` was called or the map + // was never populated at all. Either is a bug; surface it. + bool looksBare = !spec.empty() && spec[0] != '/' && spec[0] != '.' && + spec.find("://") == std::string::npos && + spec.find('\\') == std::string::npos; + if (looksBare) { + Log(@"[resolver][import-map][empty] bare='%s' — g_importMap is EMPTY (was it ever configured? expected ~200-500 entries)", spec.c_str()); } - // In release, fall through to normal path if HTTP load failed + } + + // ── Early absolute-HTTP fast path ───────────────────────────── + // If the specifier itself is an absolute HTTP(S) URL, resolve it immediately via + // the HTTP loader and return before any filesystem candidate logic runs. + // Security: HttpFetchText gates remote module access centrally. + if (StartsWith(spec, "http://") || StartsWith(spec, "https://")) { + return LoadHttpModuleForUrl(isolate, context, spec); } // Debug: Log all module resolution attempts, especially for @nativescript/core/globals @@ -849,43 +1796,7 @@ static bool IsDocumentsPath(const std::string& path) { if (IsScriptLoadingLogEnabled()) { Log(@"[resolver][http-rel] base=%s spec=%s -> %s", referrerPath.c_str(), spec.c_str(), resolvedHttp.c_str()); } - std::string key = CanonicalizeHttpUrlKey(resolvedHttp); - // If we've already compiled this URL, return it immediately - auto itExisting = g_moduleRegistry.find(key); - if (itExisting != g_moduleRegistry.end()) { - v8::Local existing = itExisting->second.Get(isolate); - if (!existing.IsEmpty()) { - v8::Module::Status st = existing->GetStatus(); - if (st == v8::Module::kErrored) { - if (IsScriptLoadingLogEnabled()) { - Log(@"[resolver][http-cache] dropping errored %s", key.c_str()); - } - RemoveModuleFromRegistry(key); - } else { - if (IsScriptLoadingLogEnabled()) { - Log(@"[resolver][http-cache] hit %s", key.c_str()); - } - return v8::MaybeLocal(existing); - } - } - } - std::string body; std::string ct; int status = 0; - if (HttpFetchText(resolvedHttp, body, ct, status) && !body.empty()) { - v8::MaybeLocal m = CompileModuleForResolveRegisterOnly(isolate, context, body, key); - if (!m.IsEmpty()) { - v8::Local mod; - if (m.ToLocal(&mod)) { - return m; - } - } - } else { - if (RuntimeConfig.IsDebug) { - std::string msg = "HTTP import failed: " + resolvedHttp + " (status=" + std::to_string(status) + ")"; - isolate->ThrowException(v8::Exception::Error(tns::ToV8String(isolate, msg.c_str()))); - return v8::MaybeLocal(); - } - } - // In release, fall through to normal resolution if fetch failed + return LoadHttpModuleForUrl(isolate, context, resolvedHttp); } } @@ -987,91 +1898,34 @@ static bool IsDocumentsPath(const std::string& path) { Log(@"[resolver][abs] spec=%s base=%s baseNoApp=%s", spec.c_str(), base.c_str(), baseNoApp.c_str()); } - } else if (IsScriptLoadingLogEnabled()) { - Log(@"[resolver][abs] spec=%s base=%s", spec.c_str(), base.c_str()); - } - } else { - // Bare specifier – resolve relative to the application root directory - std::string base = NormalizePath(RuntimeConfig.ApplicationPath + "/" + spec); - candidateBases.push_back(base); - - // Additional heuristic: bundlers often encode path separators as underscores in - // chunk IDs (e.g. "src_app_components_foo_bar_ts.mjs"). Try converting - // those underscores back to slashes and look for that file as well. - std::string withSlashes = spec; - std::replace(withSlashes.begin(), withSlashes.end(), '_', '/'); - std::string baseSlashes = NormalizePath(RuntimeConfig.ApplicationPath + "/" + withSlashes); - if (baseSlashes != base) { - candidateBases.push_back(baseSlashes); - } - } - - // We'll iterate these bases and attempt to resolve to an actual file - std::string absPath; - - // If the specifier is an HTTP(S) URL, fetch via HTTP loader and return - // Security: HttpFetchText gates remote module access centrally. - if (StartsWith(spec, "http://") || StartsWith(spec, "https://")) { - std::string key = CanonicalizeHttpUrlKey(spec); - if (IsScriptLoadingLogEnabled()) { - Log(@"[http-esm][compile][begin] %s", key.c_str()); - } - // If we've already compiled this URL, return it immediately - auto itExisting = g_moduleRegistry.find(key); - if (itExisting != g_moduleRegistry.end()) { - v8::Local existing = itExisting->second.Get(isolate); - if (!existing.IsEmpty()) { - v8::Module::Status st = existing->GetStatus(); - if (st == v8::Module::kErrored) { - if (IsScriptLoadingLogEnabled()) { - Log(@"[resolver][http-cache] dropping errored %s", key.c_str()); - } - RemoveModuleFromRegistry(key); - // proceed to refetch/compile below - } else { - if (IsScriptLoadingLogEnabled()) { - Log(@"[http-esm][compile][cache-hit] %s", key.c_str()); - } - return v8::MaybeLocal(existing); - } - } - } - std::string body; std::string ct; int status = 0; - if (HttpFetchText(spec, body, ct, status) && !body.empty()) { - // IMPORTANT: During static resolution, do not instantiate/evaluate here. - // V8 is in the middle of instantiation of the importer and will instantiate this dependency. - v8::MaybeLocal m = CompileModuleForResolveRegisterOnly(isolate, context, body, key); - if (!m.IsEmpty()) { - v8::Local mod; - if (m.ToLocal(&mod)) { - if (IsScriptLoadingLogEnabled()) { - Log(@"[http-esm][compile][ok] %s bytes=%zu", key.c_str(), body.size()); - } - return m; - } - } - // Compile failed: avoid misleading filesystem fallback in debug; surface error clearly - if (IsScriptLoadingLogEnabled()) { - Log(@"[http-esm][compile][fail][unknown] %s bytes=%zu", key.c_str(), body.size()); - } - if (RuntimeConfig.IsDebug) { - std::string msg = "HTTP import compile failed: " + spec; - isolate->ThrowException(v8::Exception::Error(tns::ToV8String(isolate, msg.c_str()))); - return v8::MaybeLocal(); - } - } else { - if (RuntimeConfig.IsDebug) { - std::string msg = "HTTP import failed: " + spec + " (status=" + std::to_string(status) + ")"; - isolate->ThrowException(v8::Exception::Error(tns::ToV8String(isolate, msg.c_str()))); - } - if (IsScriptLoadingLogEnabled()) { - Log(@"[http-esm][compile][fail][network] %s status=%d", key.c_str(), status); - } - // Regardless of build, do not fall back to filesystem for absolute HTTP specifiers in dev. - return v8::MaybeLocal(); + } else if (IsScriptLoadingLogEnabled()) { + Log(@"[resolver][abs] spec=%s base=%s", spec.c_str(), base.c_str()); + } + } else { + // Bare specifier – resolve relative to the application root directory + std::string base = NormalizePath(RuntimeConfig.ApplicationPath + "/" + spec); + candidateBases.push_back(base); + + // Additional heuristic: bundlers often encode path separators as underscores in + // chunk IDs (e.g. "src_app_components_foo_bar_ts.mjs"). Try converting + // those underscores back to slashes and look for that file as well. + std::string withSlashes = spec; + std::replace(withSlashes.begin(), withSlashes.end(), '_', '/'); + std::string baseSlashes = NormalizePath(RuntimeConfig.ApplicationPath + "/" + withSlashes); + if (baseSlashes != base) { + candidateBases.push_back(baseSlashes); } } + // We'll iterate these bases and attempt to resolve to an actual file + std::string absPath; + + // If the specifier is an HTTP(S) URL, fetch via HTTP loader and return + // Security: HttpFetchText gates remote module access centrally. + if (StartsWith(spec, "http://") || StartsWith(spec, "https://")) { + return LoadHttpModuleForUrl(isolate, context, spec); + } + // Utility: returns true iff `p` exists AND is a regular file (not directory) auto isFile = [](const std::string& p) -> bool { std::string normalized = NormalizePath(p); @@ -1098,7 +1952,8 @@ static bool IsDocumentsPath(const std::string& path) { // If a candidate accidentally embeds a collapsed HTTP URL like '/app/http:/host/...', // reconstruct the HTTP URL and resolve via the HTTP loader instead of touching the filesystem. // Security: HttpFetchText gates remote module access centrally. - auto rerouteHttpIfEmbedded = [&](const std::string& p) -> bool { + auto rerouteHttpIfEmbedded = [&](const std::string& p, + v8::MaybeLocal* moduleOut) -> bool { size_t pos1 = p.find("/http:/"); size_t pos2 = p.find("/https:/"); size_t pos = std::min(pos1 == std::string::npos ? SIZE_MAX : pos1, @@ -1113,38 +1968,14 @@ static bool IsDocumentsPath(const std::string& path) { if (!(StartsWith(tail, "http://") || StartsWith(tail, "https://"))) return false; if (IsScriptLoadingLogEnabled()) { Log(@"[resolver][http-embedded] %s -> %s", p.c_str(), tail.c_str()); } - std::string key = CanonicalizeHttpUrlKey(tail); - auto itExisting = g_moduleRegistry.find(key); - if (itExisting != g_moduleRegistry.end()) { - v8::Local existing = itExisting->second.Get(isolate); - if (!existing.IsEmpty() && existing->GetStatus() != v8::Module::kErrored) { - return !v8::MaybeLocal(existing).IsEmpty(); - } - } - std::string body; std::string ct; int status = 0; - if (HttpFetchText(tail, body, ct, status) && !body.empty()) { - v8::MaybeLocal m = CompileModuleForResolveRegisterOnly(isolate, context, body, key); - if (!m.IsEmpty()) { - v8::Local mod; if (m.ToLocal(&mod)) { return true; } - } - } - if (RuntimeConfig.IsDebug) { - std::string msg = "HTTP embedded import failed: " + tail; - isolate->ThrowException(v8::Exception::Error(tns::ToV8String(isolate, msg.c_str()))); + if (moduleOut != nullptr) { + *moduleOut = LoadHttpModuleForUrl(isolate, context, tail); } - return false; + return true; }; - if (rerouteHttpIfEmbedded(absPath)) { - // Return the module from registry; V8 will pick it up - std::string key = CanonicalizeHttpUrlKey(absPath.substr(absPath.find("http"))); - auto itExisting = g_moduleRegistry.find(key); - if (itExisting != g_moduleRegistry.end()) { - v8::Local existing = itExisting->second.Get(isolate); - if (!existing.IsEmpty()) { - return v8::MaybeLocal(existing); - } - } - // If not present, fall through to normal checks + v8::MaybeLocal embeddedHttpModule; + if (rerouteHttpIfEmbedded(absPath, &embeddedHttpModule)) { + return embeddedHttpModule; } bool existsNow = isFile(absPath); @@ -1234,6 +2065,7 @@ static bool IsDocumentsPath(const std::string& path) { } } absPath = NormalizePath(absPath); + const std::string registryAbsPath = CanonicalizeRegistryKey(absPath); if (!isFile(absPath)) { // Debug: Log resolution failure for worker context @@ -1366,86 +2198,16 @@ static bool IsDocumentsPath(const std::string& path) { // Special handling for JSON imports (e.g. import data from './foo.json' assert {type:'json'}) if (absPath.size() >= 5 && absPath.compare(absPath.size() - 5, 5, ".json") == 0) { - // Debug: Log JSON module handling for worker context - if (cache->isWorker) { - printf("ResolveModuleCallback: Worker handling JSON module '%s'\n", absPath.c_str()); - } - - // Read file contents - std::string jsonText = tns::ReadText(absPath); - - // Debug: Log JSON content preview for worker context - if (cache->isWorker) { - std::string preview = jsonText.length() > 200 ? jsonText.substr(0, 200) + "..." : jsonText; - printf("ResolveModuleCallback: Worker JSON content preview: %s\n", preview.c_str()); - } - - // Build a small ES module that just exports the parsed JSON as default - std::string moduleSource = "export default " + jsonText + ";"; - - v8::Local sourceText = tns::ToV8String(isolate, moduleSource); - // Build URL for stack traces - std::string base = ReplaceAll(absPath, RuntimeConfig.BaseDir, ""); - std::string url = "file://" + base; - - v8::Local urlString; - if (!v8::String::NewFromUtf8(isolate, url.c_str(), v8::NewStringType::kNormal) - .ToLocal(&urlString)) { - if (RuntimeConfig.IsDebug) { - Log(@"Debug mode - Failed to create URL string for JSON module"); - return v8::MaybeLocal(); - } else { - isolate->ThrowException(v8::Exception::Error( - tns::ToV8String(isolate, "Failed to create URL string for JSON module"))); - return v8::MaybeLocal(); - } - } - - v8::ScriptOrigin origin(isolate, urlString, 0, 0, false, -1, v8::Local(), false, - false, true /* is_module */); - - v8::ScriptCompiler::Source src(sourceText, origin); - - v8::Local jsonModule; - if (!v8::ScriptCompiler::CompileModule(isolate, &src).ToLocal(&jsonModule)) { - if (RuntimeConfig.IsDebug) { - Log(@"Debug mode - Failed to compile JSON module"); - return v8::MaybeLocal(); - } else { - isolate->ThrowException( - v8::Exception::SyntaxError(tns::ToV8String(isolate, "Failed to compile JSON module"))); - return v8::MaybeLocal(); - } - } - - // No imports inside this module, so instantiate directly - if (!jsonModule->InstantiateModule(context, &ResolveModuleCallback).FromMaybe(false)) { - return v8::MaybeLocal(); - } - - // Evaluate immediately so namespace is populated - v8::MaybeLocal evalResult = jsonModule->Evaluate(context); - if (evalResult.IsEmpty()) { - return v8::MaybeLocal(); - } - - // Store in registry and return - with safe Global handle management - auto it = g_moduleRegistry.find(absPath); - if (it != g_moduleRegistry.end()) { - // Clear the existing Global handle before replacing it - it->second.Reset(); - } - g_moduleRegistry[absPath].Reset(isolate, jsonModule); - return v8::MaybeLocal(jsonModule); + return CompileJsonAsEsModule(isolate, context, absPath, registryAbsPath, cache->isWorker); } // 5) If we've already compiled that module (non-JSON case), return it - auto it = g_moduleRegistry.find(absPath); + auto it = g_moduleRegistry.find(registryAbsPath); if (it != g_moduleRegistry.end()) { v8::Local existing = it->second.Get(isolate); v8::Module::Status status = existing.IsEmpty() ? v8::Module::kErrored : existing->GetStatus(); bool inCurrentStack = - std::find(g_moduleResolutionStack.begin(), g_moduleResolutionStack.end(), absPath) != + std::find(g_moduleResolutionStack.begin(), g_moduleResolutionStack.end(), registryAbsPath) != g_moduleResolutionStack.end(); bool shouldReuse = !existing.IsEmpty() && status != v8::Module::kErrored; @@ -1478,15 +2240,15 @@ static bool IsDocumentsPath(const std::string& path) { size_t reentryCount = 0; bool unfinished = status == v8::Module::kUninstantiated || status == v8::Module::kInstantiating || status == v8::Module::kEvaluating; - bool moduleInFlight = g_modulesInFlight.find(absPath) != g_modulesInFlight.end(); - bool pendingReset = g_modulesPendingReset.find(absPath) != g_modulesPendingReset.end(); + bool moduleInFlight = g_modulesInFlight.find(registryAbsPath) != g_modulesInFlight.end(); + bool pendingReset = g_modulesPendingReset.find(registryAbsPath) != g_modulesPendingReset.end(); bool treatAsRecursive = false; const std::string parentKey = referrerPath.empty() ? "" : referrerPath; if (shouldReuse && status != v8::Module::kEvaluated) { if (moduleInFlight) { - auto& parentSet = g_moduleReentryParents[absPath]; - bool isSelfImport = !referrerPath.empty() && referrerPath == absPath; + auto& parentSet = g_moduleReentryParents[registryAbsPath]; + bool isSelfImport = !referrerPath.empty() && referrerPath == registryAbsPath; bool hasParentInfo = !parentKey.empty() && parentKey != ""; bool isDynamicDocumentsModule = IsDocumentsPath(absPath); bool parentAlreadyRecorded = false; @@ -1497,10 +2259,10 @@ static bool IsDocumentsPath(const std::string& path) { parentAlreadyRecorded = true; } - auto primaryIt = g_modulePrimaryImporters.find(absPath); + auto primaryIt = g_modulePrimaryImporters.find(registryAbsPath); if (hasParentInfo && primaryIt == g_modulePrimaryImporters.end()) { - g_modulePrimaryImporters[absPath] = parentKey; - primaryIt = g_modulePrimaryImporters.find(absPath); + g_modulePrimaryImporters[registryAbsPath] = parentKey; + primaryIt = g_modulePrimaryImporters.find(registryAbsPath); } if (isSelfImport) { @@ -1540,7 +2302,7 @@ static bool IsDocumentsPath(const std::string& path) { } v8::Local fallback; if (unfinished) { - auto fallbackIt = g_moduleFallbackRegistry.find(absPath); + auto fallbackIt = g_moduleFallbackRegistry.find(registryAbsPath); if (fallbackIt != g_moduleFallbackRegistry.end()) { fallback = fallbackIt->second.Get(isolate); } @@ -1573,7 +2335,7 @@ static bool IsDocumentsPath(const std::string& path) { } if (fallback.IsEmpty()) { - auto aliasRegIt = g_moduleRegistry.find(alias); + auto aliasRegIt = g_moduleRegistry.find(CanonicalizeRegistryKey(alias)); if (aliasRegIt != g_moduleRegistry.end()) { v8::Local aliasModule = aliasRegIt->second.Get(isolate); if (!aliasModule.IsEmpty() && aliasModule->GetStatus() == v8::Module::kEvaluated) { @@ -1583,7 +2345,7 @@ static bool IsDocumentsPath(const std::string& path) { } if (!fallback.IsEmpty()) { - g_moduleFallbackRegistry[absPath].Reset(isolate, fallback); + g_moduleFallbackRegistry[registryAbsPath].Reset(isolate, fallback); if (!relative.empty()) { g_moduleFallbackByRelative[relative].Reset(isolate, fallback); } @@ -1615,11 +2377,11 @@ static bool IsDocumentsPath(const std::string& path) { } if (treatAsRecursive) { - auto reentryIt = g_moduleReentryCounts.find(absPath); + auto reentryIt = g_moduleReentryCounts.find(registryAbsPath); if (reentryIt != g_moduleReentryCounts.end()) { reentryCount = ++reentryIt->second; } else { - reentryCount = ++g_moduleReentryCounts[absPath]; + reentryCount = ++g_moduleReentryCounts[registryAbsPath]; } if (reentryCount > kMaxModuleReentryCount) { @@ -1636,7 +2398,7 @@ static bool IsDocumentsPath(const std::string& path) { } if (unfinished && moduleInFlight && reentryCount > 0) { - g_modulesPendingReset.insert(absPath); + g_modulesPendingReset.insert(registryAbsPath); pendingReset = true; if (IsScriptLoadingLogEnabled()) { Log(@"[resolver] scheduling reset for unfinished module %s (status=%s, re-entry=%lu)", @@ -1645,7 +2407,7 @@ static bool IsDocumentsPath(const std::string& path) { } } } else { - auto existingCountIt = g_moduleReentryCounts.find(absPath); + auto existingCountIt = g_moduleReentryCounts.find(registryAbsPath); if (existingCountIt != g_moduleReentryCounts.end()) { reentryCount = existingCountIt->second; } @@ -1659,8 +2421,8 @@ static bool IsDocumentsPath(const std::string& path) { } } else { shouldReuse = false; - g_modulesPendingReset.erase(absPath); - g_modulePrimaryImporters.erase(absPath); + g_modulesPendingReset.erase(registryAbsPath); + g_modulePrimaryImporters.erase(registryAbsPath); if (IsScriptLoadingLogEnabled()) { Log(@"[resolver] dropping module awaiting reset %s (status=%s)", absPath.c_str(), ModuleStatusToString(status)); @@ -1680,7 +2442,7 @@ static bool IsDocumentsPath(const std::string& path) { Log(@" ↳ returning module before evaluation (status=%s)", statusStr); } if (moduleInFlight) { - auto primaryIt = g_modulePrimaryImporters.find(absPath); + auto primaryIt = g_modulePrimaryImporters.find(registryAbsPath); const char* owner = primaryIt != g_modulePrimaryImporters.end() ? primaryIt->second.c_str() : ""; Log(@" ↳ module still evaluating; primary importer=%s, requester=%s", owner, parentKey.c_str()); @@ -1700,11 +2462,11 @@ static bool IsDocumentsPath(const std::string& path) { } if (!existing.IsEmpty() && status == v8::Module::kEvaluated) { - auto fallbackIt = g_moduleFallbackRegistry.find(absPath); + auto fallbackIt = g_moduleFallbackRegistry.find(registryAbsPath); if (fallbackIt != g_moduleFallbackRegistry.end()) { fallbackIt->second.Reset(); } - g_moduleFallbackRegistry[absPath].Reset(isolate, existing); + g_moduleFallbackRegistry[registryAbsPath].Reset(isolate, existing); if (IsScriptLoadingLogEnabled()) { Log(@"[resolver] cached evaluated module as fallback for %s", absPath.c_str()); } @@ -1719,7 +2481,7 @@ static bool IsDocumentsPath(const std::string& path) { absPath.c_str()); } - auto cycleIt = std::find(g_moduleResolutionStack.begin(), g_moduleResolutionStack.end(), absPath); + auto cycleIt = std::find(g_moduleResolutionStack.begin(), g_moduleResolutionStack.end(), registryAbsPath); if (cycleIt != g_moduleResolutionStack.end()) { if (IsScriptLoadingLogEnabled()) { Log(@"[resolver] Detected recursive load for %s (already in stack length %lu)", @@ -1729,7 +2491,7 @@ static bool IsDocumentsPath(const std::string& path) { } } - auto existing = g_moduleRegistry.find(absPath); + auto existing = g_moduleRegistry.find(registryAbsPath); if (existing != g_moduleRegistry.end()) { return v8::MaybeLocal(existing->second.Get(isolate)); } @@ -1745,7 +2507,7 @@ static bool IsDocumentsPath(const std::string& path) { return v8::MaybeLocal(); } - ResolutionStackGuard stackGuard(isolate, g_moduleResolutionStack, absPath); + ResolutionStackGuard stackGuard(isolate, g_moduleResolutionStack, registryAbsPath); if (IsScriptLoadingLogEnabled()) { Log(@"[resolver] → LoadScript %s", absPath.c_str()); } @@ -1760,7 +2522,7 @@ static bool IsDocumentsPath(const std::string& path) { return v8::MaybeLocal(); } // LoadScript will have added it into g_moduleRegistry under absPath - auto it2 = g_moduleRegistry.find(absPath); + auto it2 = g_moduleRegistry.find(registryAbsPath); if (it2 == g_moduleRegistry.end()) { // something went wrong return v8::MaybeLocal(); @@ -1825,7 +2587,7 @@ static bool IsDocumentsPath(const std::string& path) { v8::MaybeLocal modMaybe = CompileModuleFromSource(isolate, context, kEmptySrc, url); v8::Local mod; if (modMaybe.ToLocal(&mod)) { - g_moduleRegistry[url].Reset(isolate, mod); + g_moduleRegistry[CanonicalizeRegistryKey(url)].Reset(isolate, mod); if (mod->GetStatus() != v8::Module::kEvaluated) { if (mod->Evaluate(context).IsEmpty()) { resolver_immediate->Reject(context, v8::Exception::Error(tns::ToV8String(isolate, "Evaluation failed for empty module"))).FromMaybe(false); @@ -1890,6 +2652,45 @@ static bool IsDocumentsPath(const std::string& path) { return v8::MaybeLocal(); } + // ── Import map resolution for dynamic import() ──────────────── + if (!g_importMap.empty() && !normalizedSpec.empty() && normalizedSpec != "@") { + std::string mapped = LookupImportMap(normalizedSpec); + // If direct lookup failed, try normalizing Vite-rewritten specifiers + if (mapped.empty()) { + std::string normalized = NormalizeViteSpecifier(normalizedSpec); + if (!normalized.empty()) { + mapped = LookupImportMap(normalized); + if (!mapped.empty() && IsScriptLoadingLogEnabled()) { + Log(@"[dyn-import][import-map] normalized: %s -> %s -> %s", + normalizedSpec.c_str(), normalized.c_str(), mapped.c_str()); + } + } + } + if (!mapped.empty()) { + if (StartsWith(mapped, "ns-vendor://")) { + std::string vendorId = mapped.substr(12); + if (IsScriptLoadingLogEnabled()) { + Log(@"[dyn-import][import-map] vendor: %s -> %s", normalizedSpec.c_str(), vendorId.c_str()); + } + v8::MaybeLocal vendorMod = ResolveFromVendorRegistry(isolate, context, vendorId); + v8::Local mod; + if (vendorMod.ToLocal(&mod) && mod->GetStatus() == v8::Module::kEvaluated) { + resolver->Resolve(context, mod->GetModuleNamespace()).FromMaybe(false); + return scope.Escape(resolver->GetPromise()); + } + // Fall through to normal resolution if vendor resolve failed + } else { + // Mapped to an HTTP URL or other specifier + normalizedSpec = mapped; + specifier = tns::ToV8String(isolate, normalizedSpec.c_str()); + specStr = [NSString stringWithUTF8String:normalizedSpec.c_str()]; + if (IsScriptLoadingLogEnabled()) { + Log(@"[dyn-import][import-map] rewrite: %s -> %s", rawSpec.c_str(), normalizedSpec.c_str()); + } + } + } + } + // Re-use the static resolver to locate / compile the module. try { // Defensive guard: some dev-time toolchains may emit a stray import('@') during bootstrap. @@ -1903,7 +2704,7 @@ static bool IsDocumentsPath(const std::string& path) { v8::MaybeLocal modMaybe = CompileModuleFromSource(isolate, context, kEmptySrc, url); v8::Local mod; if (modMaybe.ToLocal(&mod)) { - g_moduleRegistry[url].Reset(isolate, mod); + g_moduleRegistry[CanonicalizeRegistryKey(url)].Reset(isolate, mod); if (mod->GetStatus() != v8::Module::kEvaluated) { if (mod->Evaluate(context).IsEmpty()) { resolver->Reject(context, v8::Exception::Error(tns::ToV8String(isolate, "Evaluation failed for empty module"))).FromMaybe(false); @@ -1915,6 +2716,328 @@ static bool IsDocumentsPath(const std::string& path) { } } + // ── Blob URL support (e.g., blob:nativescript/) ── + // Also useful for HMR updates where we can load a blob URL + // We retrieve the blob content from the global BLOB_STORE via URL.InternalAccessor.getData() + // and compile/execute it as an ES module. + if (!normalizedSpec.empty() && StartsWith(normalizedSpec, "blob:nativescript/")) { + const std::string blobRegistryKey = CanonicalizeRegistryKey(normalizedSpec); + + if (IsScriptLoadingLogEnabled()) { + Log(@"[dyn-import][blob] trying blob URL %s key=%s", normalizedSpec.c_str(), + blobRegistryKey.c_str()); + } + + auto existingIt = g_moduleRegistry.find(blobRegistryKey); + if (existingIt != g_moduleRegistry.end()) { + v8::Local existing = existingIt->second.Get(isolate); + if (!existing.IsEmpty()) { + v8::Module::Status existingStatus = existing->GetStatus(); + if (IsScriptLoadingLogEnabled()) { + Log(@"[dyn-import][blob-cache] hit %s status=%s", blobRegistryKey.c_str(), + ModuleStatusToString(existingStatus)); + } + + if (existingStatus == v8::Module::kErrored) { + RemoveModuleFromRegistry(blobRegistryKey); + } else if (IsModuleEvaluationInProgress(existingStatus)) { + g_modulesInFlight.insert(blobRegistryKey); + g_httpDynamicWaiters[blobRegistryKey].emplace_back(isolate, resolver); + if (IsScriptLoadingLogEnabled()) { + Log(@"[dyn-import][blob-await] queued waiter for %s status=%s", + blobRegistryKey.c_str(), ModuleStatusToString(existingStatus)); + } + return scope.Escape(resolver->GetPromise()); + } else { + resolver->Resolve(context, existing->GetModuleNamespace()).FromMaybe(false); + return scope.Escape(resolver->GetPromise()); + } + } else { + RemoveModuleFromRegistry(blobRegistryKey); + } + } + + if (g_modulesInFlight.find(blobRegistryKey) != g_modulesInFlight.end()) { + if (IsScriptLoadingLogEnabled()) { + Log(@"[dyn-import][blob] coalesce in-flight %s", blobRegistryKey.c_str()); + } + g_httpDynamicWaiters[blobRegistryKey].emplace_back(isolate, resolver); + return scope.Escape(resolver->GetPromise()); + } + + g_modulesInFlight.insert(blobRegistryKey); + g_httpDynamicWaiters[blobRegistryKey].emplace_back(isolate, resolver); + + // Call URL.InternalAccessor.getData(url) to retrieve the blob data + v8::TryCatch tc(isolate); + v8::Local globalObj = context->Global(); + + // Get URL constructor + v8::Local urlCtorVal; + if (!globalObj->Get(context, tns::ToV8String(isolate, "URL")).ToLocal(&urlCtorVal) || !urlCtorVal->IsFunction()) { + if (IsScriptLoadingLogEnabled()) { + Log(@"[dyn-import][blob] URL constructor not found"); + } + RejectHttpDynamicWaiters( + isolate, context, blobRegistryKey, + v8::Exception::Error(tns::ToV8String(isolate, "URL constructor not available"))); + return scope.Escape(resolver->GetPromise()); + } + v8::Local urlCtor = urlCtorVal.As(); + + // Get URL.InternalAccessor + v8::Local internalAccessorVal; + if (!urlCtor->Get(context, tns::ToV8String(isolate, "InternalAccessor")).ToLocal(&internalAccessorVal) || !internalAccessorVal->IsObject()) { + if (IsScriptLoadingLogEnabled()) { + Log(@"[dyn-import][blob] URL.InternalAccessor not found"); + } + RejectHttpDynamicWaiters( + isolate, context, blobRegistryKey, + v8::Exception::Error(tns::ToV8String(isolate, "URL.InternalAccessor not available"))); + return scope.Escape(resolver->GetPromise()); + } + v8::Local internalAccessor = internalAccessorVal.As(); + + // Get URL.InternalAccessor.getData function + v8::Local getDataVal; + if (!internalAccessor->Get(context, tns::ToV8String(isolate, "getData")).ToLocal(&getDataVal) || !getDataVal->IsFunction()) { + if (IsScriptLoadingLogEnabled()) { + Log(@"[dyn-import][blob] URL.InternalAccessor.getData not found"); + } + RejectHttpDynamicWaiters( + isolate, context, blobRegistryKey, + v8::Exception::Error( + tns::ToV8String(isolate, "URL.InternalAccessor.getData not available"))); + return scope.Escape(resolver->GetPromise()); + } + v8::Local getDataFn = getDataVal.As(); + + // Call getData(url) + v8::Local urlArg = tns::ToV8String(isolate, normalizedSpec.c_str()); + v8::Local blobDataVal; + if (!getDataFn->Call(context, internalAccessor, 1, &urlArg).ToLocal(&blobDataVal) || blobDataVal->IsNullOrUndefined()) { + if (IsScriptLoadingLogEnabled()) { + Log(@"[dyn-import][blob] blob not found in BLOB_STORE: %s", normalizedSpec.c_str()); + } + std::string msg = "Blob not found: " + normalizedSpec; + RejectHttpDynamicWaiters( + isolate, context, blobRegistryKey, + v8::Exception::Error(tns::ToV8String(isolate, msg.c_str()))); + return scope.Escape(resolver->GetPromise()); + } + + // blobDataVal should be {blob: Blob, type: string, ext: string} + // We need to get the text from the Blob + if (!blobDataVal->IsObject()) { + if (IsScriptLoadingLogEnabled()) { + Log(@"[dyn-import][blob] blob data is not an object"); + } + RejectHttpDynamicWaiters( + isolate, context, blobRegistryKey, + v8::Exception::Error(tns::ToV8String(isolate, "Invalid blob data"))); + return scope.Escape(resolver->GetPromise()); + } + v8::Local blobData = blobDataVal.As(); + + // Get the actual Blob object + v8::Local blobVal; + if (!blobData->Get(context, tns::ToV8String(isolate, "blob")).ToLocal(&blobVal) || !blobVal->IsObject()) { + if (IsScriptLoadingLogEnabled()) { + Log(@"[dyn-import][blob] blob property not found"); + } + RejectHttpDynamicWaiters( + isolate, context, blobRegistryKey, + v8::Exception::Error(tns::ToV8String(isolate, "Blob object not found"))); + return scope.Escape(resolver->GetPromise()); + } + v8::Local blobObj = blobVal.As(); + + // Call blob.text() to get the source code as a Promise + v8::Local textFnVal; + if (!blobObj->Get(context, tns::ToV8String(isolate, "text")).ToLocal(&textFnVal) || !textFnVal->IsFunction()) { + if (IsScriptLoadingLogEnabled()) { + Log(@"[dyn-import][blob] Blob.text() not available"); + } + RejectHttpDynamicWaiters( + isolate, context, blobRegistryKey, + v8::Exception::Error(tns::ToV8String(isolate, "Blob.text() not available"))); + return scope.Escape(resolver->GetPromise()); + } + v8::Local textFn = textFnVal.As(); + + v8::Local textPromiseVal; + if (!textFn->Call(context, blobObj, 0, nullptr).ToLocal(&textPromiseVal) || !textPromiseVal->IsPromise()) { + if (IsScriptLoadingLogEnabled()) { + Log(@"[dyn-import][blob] Blob.text() did not return a Promise"); + } + RejectHttpDynamicWaiters( + isolate, context, blobRegistryKey, + v8::Exception::Error(tns::ToV8String(isolate, "Blob.text() failed"))); + return scope.Escape(resolver->GetPromise()); + } + v8::Local textPromise = textPromiseVal.As(); + + // Create data structure to pass to the callbacks. + struct BlobImportData { + v8::Global ctx; + std::string blobUrl; + std::string registryKey; + }; + auto* data = new BlobImportData{ + v8::Global(isolate, context), + normalizedSpec, + blobRegistryKey, + }; + + // Success callback: compile and execute the module. + auto onFulfilled = [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::HandleScope hs(iso); + if (!info.Data()->IsExternal()) return; + auto* d = static_cast(info.Data().As()->Value()); + v8::Local ctx = d->ctx.Get(iso); + + if (info.Length() < 1 || !info[0]->IsString()) { + RejectHttpDynamicWaiters( + iso, ctx, d->registryKey, + v8::Exception::Error(tns::ToV8String(iso, "Blob text is not a string"))); + delete d; + return; + } + + v8::String::Utf8Value codeUtf8(iso, info[0]); + std::string code = *codeUtf8 ? *codeUtf8 : ""; + + if (IsScriptLoadingLogEnabled()) { + Log(@"[dyn-import][blob] compiling blob module, code length=%zu", code.size()); + } + + v8::MaybeLocal modMaybe = + CompileModuleForResolveRegisterOnly(iso, ctx, code, d->blobUrl); + v8::Local mod; + if (!modMaybe.ToLocal(&mod)) { + RejectHttpDynamicWaiters( + iso, ctx, d->registryKey, + v8::Exception::Error(tns::ToV8String(iso, "Failed to compile blob module"))); + delete d; + return; + } + + if (mod->GetStatus() == v8::Module::kUninstantiated && + !mod->InstantiateModule(ctx, &ResolveModuleCallback).FromMaybe(false)) { + RemoveModuleFromRegistry(d->registryKey); + RejectHttpDynamicWaiters( + iso, ctx, d->registryKey, + v8::Exception::Error( + tns::ToV8String(iso, "Failed to instantiate blob module"))); + delete d; + return; + } + + if (IsModuleEvaluationInProgress(mod->GetStatus())) { + if (IsScriptLoadingLogEnabled()) { + Log(@"[dyn-import][blob] waiting on existing evaluation for %s status=%s", + d->registryKey.c_str(), ModuleStatusToString(mod->GetStatus())); + } + delete d; + return; + } + + if (mod->GetStatus() != v8::Module::kEvaluated) { + v8::Local evalResult; + if (!mod->Evaluate(ctx).ToLocal(&evalResult)) { + RemoveModuleFromRegistry(d->registryKey); + RejectHttpDynamicWaiters( + iso, ctx, d->registryKey, + v8::Exception::Error( + tns::ToV8String(iso, "Failed to evaluate blob module"))); + delete d; + return; + } + + if (!evalResult.IsEmpty() && evalResult->IsPromise()) { + struct BlobEvalData { + std::string registryKey; + v8::Global ctx; + v8::Global mod; + }; + + auto* evalData = new BlobEvalData{ + d->registryKey, + v8::Global(iso, ctx), + v8::Global(iso, mod), + }; + + auto onEvalFulfilled = [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::HandleScope hs(iso); + if (!info.Data()->IsExternal()) return; + auto* d = static_cast(info.Data().As()->Value()); + v8::Local ctx = d->ctx.Get(iso); + v8::Local mod = d->mod.Get(iso); + ResolveHttpDynamicWaiters(iso, ctx, d->registryKey, mod); + delete d; + }; + + auto onEvalRejected = [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::HandleScope hs(iso); + if (!info.Data()->IsExternal()) return; + auto* d = static_cast(info.Data().As()->Value()); + v8::Local ctx = d->ctx.Get(iso); + v8::Local reason = + info.Length() > 0 + ? info[0] + : v8::Exception::Error( + tns::ToV8String(iso, "Blob module evaluation failed")); + RemoveModuleFromRegistry(d->registryKey); + RejectHttpDynamicWaiters(iso, ctx, d->registryKey, reason); + delete d; + }; + + v8::Local evalPromise = evalResult.As(); + v8::Local onEvalFulfilledFn = + v8::Function::New(ctx, onEvalFulfilled, + v8::External::New(iso, evalData)) + .ToLocalChecked(); + v8::Local onEvalRejectedFn = + v8::Function::New(ctx, onEvalRejected, + v8::External::New(iso, evalData)) + .ToLocalChecked(); + evalPromise->Then(ctx, onEvalFulfilledFn, onEvalRejectedFn) + .FromMaybe(v8::Local()); + delete d; + return; + } + } + + ResolveHttpDynamicWaiters(iso, ctx, d->registryKey, mod); + delete d; + }; + + // Error callback + auto onRejected = [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::HandleScope hs(iso); + if (!info.Data()->IsExternal()) return; + auto* d = static_cast(info.Data().As()->Value()); + v8::Local ctx = d->ctx.Get(iso); + v8::Local reason = + info.Length() > 0 + ? info[0] + : v8::Exception::Error(tns::ToV8String(iso, "Blob text() failed")); + RejectHttpDynamicWaiters(iso, ctx, d->registryKey, reason); + delete d; + }; + + v8::Local onFulfilledFn = v8::Function::New(context, onFulfilled, v8::External::New(isolate, data)).ToLocalChecked(); + v8::Local onRejectedFn = v8::Function::New(context, onRejected, v8::External::New(isolate, data)).ToLocalChecked(); + + textPromise->Then(context, onFulfilledFn, onRejectedFn).FromMaybe(v8::Local()); + + return scope.Escape(resolver->GetPromise()); + } + // If spec is an HTTP(S) URL, try HTTP fetch+compile directly // Security: HttpFetchText gates remote module access centrally. if (!normalizedSpec.empty() && (StartsWith(normalizedSpec, "http://") || StartsWith(normalizedSpec, "https://"))) { @@ -1922,27 +3045,42 @@ static bool IsDocumentsPath(const std::string& path) { Log(@"[dyn-import][http-loader] trying URL %s", normalizedSpec.c_str()); } std::string key = CanonicalizeHttpUrlKey(normalizedSpec); - // Diagnostic: confirm SFC canonical key retains versioning query - if (IsScriptLoadingLogEnabled()) { - bool diagIsSfc = normalizedSpec.find("/@ns/sfc/") != std::string::npos; - if (diagIsSfc) { - Log(@"[dyn-import][sfc-key] spec=%s key=%s", normalizedSpec.c_str(), key.c_str()); - } - } - // Classify SFC base vs variant: base is /@ns/sfc without a specific type (script/template/style) - bool specIsSfc = normalizedSpec.find("/@ns/sfc/") != std::string::npos; - bool specIsAsm = normalizedSpec.find("/@ns/asm/") != std::string::npos; - bool specHasTypeScript = normalizedSpec.find("type=script") != std::string::npos; - bool specHasTypeTemplate = normalizedSpec.find("type=template") != std::string::npos; - bool specHasTypeStyle = normalizedSpec.find("type=style") != std::string::npos; - bool isSfcVariant = specIsSfc && (specHasTypeScript || specHasTypeTemplate || specHasTypeStyle); - // Base SFC has no explicit type param; variants carry type=script|template|style - bool isSfcBase = (specIsSfc && !isSfcVariant) || specIsAsm; - if (isSfcBase) { + // Volatile pattern check: if the URL matches any configured volatile pattern, + // evict the cached module so we always re-fetch. This replaces the hardcoded + // /@ns/sfc/ and /@ns/asm/ checks with a configurable system. + bool isVolatile = IsVolatileUrl(normalizedSpec); + // Backward compatibility: if no volatile patterns configured, fall back to + // hardcoded SFC/ASM detection + if (!isVolatile && g_volatilePatterns.empty()) { + bool specIsSfc = normalizedSpec.find("/@ns/sfc/") != std::string::npos; + bool specIsAsm = normalizedSpec.find("/@ns/asm/") != std::string::npos; + bool specHasTypeScript = normalizedSpec.find("type=script") != std::string::npos; + bool specHasTypeTemplate = normalizedSpec.find("type=template") != std::string::npos; + bool specHasTypeStyle = normalizedSpec.find("type=style") != std::string::npos; + bool isSfcVariant = specIsSfc && (specHasTypeScript || specHasTypeTemplate || specHasTypeStyle); + isVolatile = (specIsSfc && !isSfcVariant) || specIsAsm; + } + // Angular HMR component-update endpoint (`/@ng/component?c=&t=`) is + // inherently volatile: each save produces fresh metadata that the runtime + // must re-fetch and re-compile so `ɵɵreplaceMetadata` sees the new + // template instructions. The `t` parameter discriminates versions, but + // even with `CanonicalizeHttpUrlKey` preserving it, every save would + // otherwise leave a stale module entry behind in `g_moduleRegistry`, + // accumulating one entry per save for the entire dev session. Marking it + // as volatile evicts the previous entry on every re-import so the cache + // stays bounded AND we always serve fresh metadata to + // `ɵɵreplaceMetadata`. Without this evict, the boot-time call's + // resolved module would shadow any subsequent fetch on a path-only + // canonicalization regression and surface as "first save's metadata + // permanently stuck on screen" — exactly the symptom this fixes. + if (!isVolatile && normalizedSpec.find("/@ng/component") != std::string::npos) { + isVolatile = true; + } + if (isVolatile) { auto ex = g_moduleRegistry.find(key); if (ex != g_moduleRegistry.end()) { if (IsScriptLoadingLogEnabled()) { - Log(@"[dyn-import][http-cache] drop SFC %s", key.c_str()); + Log(@"[dyn-import][http-cache] drop volatile %s", key.c_str()); } RemoveModuleFromRegistry(key); } @@ -1961,7 +3099,15 @@ static bool IsDocumentsPath(const std::string& path) { if (itExisting != g_moduleRegistry.end()) { v8::Local existing = itExisting->second.Get(isolate); if (!existing.IsEmpty()) { + // Permanent observability: surface every HTTP dynamic-import + // cache hit so we can verify the runtime *did* drop the entry + // on invalidate. Filtered to angular component-shaped URLs to + // avoid spam from vendor chunks. Verbose-gated. if (IsScriptLoadingLogEnabled()) { + if (key.find("ns/m/") != std::string::npos || key.find(".component") != std::string::npos) { + Log(@"[ns-hmr][ios-dyn-cache] HIT %s status=%s", + key.c_str(), ModuleStatusToString(existing->GetStatus())); + } Log(@"[dyn-import][http-cache] hit %s", key.c_str()); Log(@" ↳ status=%s", ModuleStatusToString(existing->GetStatus())); } @@ -1973,6 +3119,17 @@ static bool IsDocumentsPath(const std::string& path) { } RemoveModuleFromRegistry(key); // fall through to fetch/compile path below + } else if (IsModuleEvaluationInProgress(st)) { + if (QueueHttpDynamicWaiterIfInFlight(isolate, key, existing, resolver)) { + return scope.Escape(resolver->GetPromise()); + } + + if (IsScriptLoadingLogEnabled()) { + Log(@"[dyn-import][http-cache] avoiding re-entrant Evaluate for %s status=%s", + key.c_str(), ModuleStatusToString(st)); + } + resolver->Resolve(context, existing->GetModuleNamespace()).FromMaybe(false); + return scope.Escape(resolver->GetPromise()); } else { // Ensure dynamic import semantics: resolve only after evaluation if (st != v8::Module::kEvaluated) { @@ -1982,20 +3139,29 @@ static bool IsDocumentsPath(const std::string& path) { Log(@"[dyn-import][http-cache] awaiting evaluation %s", key.c_str()); } g_httpDynamicWaiters[key].emplace_back(isolate, resolver); + if (st == v8::Module::kUninstantiated && + !existing->InstantiateModule(context, &ResolveModuleCallback).FromMaybe(false)) { + RemoveModuleFromRegistry(key); + RejectHttpDynamicWaiters( + isolate, context, key, + v8::Exception::Error( + tns::ToV8String(isolate, "Instantiation failed (http-cache hit)"))); + return scope.Escape(resolver->GetPromise()); + } + + if (IsModuleEvaluationInProgress(existing->GetStatus())) { + return scope.Escape(resolver->GetPromise()); + } + // Trigger evaluation. If TLA returns a Promise, attach then-handlers to resolve waiters upon settle. v8::Local evalResult; if (!existing->Evaluate(context).ToLocal(&evalResult)) { // Failed evaluation: reject all waiters and drop entry - auto ws = g_httpDynamicWaiters.find(key); - if (ws != g_httpDynamicWaiters.end()) { - for (auto &res : ws->second) { - v8::Local r = res.Get(isolate); - if (!r.IsEmpty()) r->Reject(context, v8::Exception::Error(tns::ToV8String(isolate, "Evaluation failed (http-cache hit)"))).FromMaybe(false); - } - g_httpDynamicWaiters.erase(ws); - } RemoveModuleFromRegistry(key); - g_modulesInFlight.erase(key); + RejectHttpDynamicWaiters( + isolate, context, key, + v8::Exception::Error( + tns::ToV8String(isolate, "Evaluation failed (http-cache hit)"))); return scope.Escape(resolver->GetPromise()); } // If Evaluate returned a Promise (top-level await), wait until it settles before resolving waiters. @@ -2011,16 +3177,7 @@ static bool IsDocumentsPath(const std::string& path) { v8::Local ctx = d->ctx.Get(iso); std::string keyLocal = d->key; v8::Local modLocal = d->mod.Get(iso); - auto ws = g_httpDynamicWaiters.find(keyLocal); - if (ws != g_httpDynamicWaiters.end()) { - v8::Local ns = modLocal->GetModuleNamespace(); - for (auto &res : ws->second) { - v8::Local r = res.Get(iso); - if (!r.IsEmpty()) r->Resolve(ctx, ns).FromMaybe(false); - } - g_httpDynamicWaiters.erase(ws); - } - g_modulesInFlight.erase(keyLocal); + ResolveHttpDynamicWaiters(iso, ctx, keyLocal, modLocal); delete d; }; auto onRejected = [](const v8::FunctionCallbackInfo& info) { @@ -2035,15 +3192,7 @@ static bool IsDocumentsPath(const std::string& path) { v8::String::Utf8Value r(iso, reason); if (*r) { Log(@"[dyn-import][http-cache][tla] rejected: %s", *r); } } - auto ws = g_httpDynamicWaiters.find(keyLocal); - if (ws != g_httpDynamicWaiters.end()) { - for (auto &res : ws->second) { - v8::Local r = res.Get(iso); - if (!r.IsEmpty()) r->Reject(ctx, reason).FromMaybe(false); - } - g_httpDynamicWaiters.erase(ws); - } - g_modulesInFlight.erase(keyLocal); + RejectHttpDynamicWaiters(iso, ctx, keyLocal, reason); delete d; }; v8::Local thenFulfillTpl = v8::FunctionTemplate::New(isolate, onFulfilled, v8::External::New(isolate, data)); @@ -2054,19 +3203,8 @@ static bool IsDocumentsPath(const std::string& path) { return scope.Escape(resolver->GetPromise()); } // Successful sync evaluation path: resolve waiters now. - { - auto ws = g_httpDynamicWaiters.find(key); - if (ws != g_httpDynamicWaiters.end()) { - v8::Local resolveVal = existing->GetModuleNamespace(); - for (auto &res : ws->second) { - v8::Local r = res.Get(isolate); - if (!r.IsEmpty()) r->Resolve(context, resolveVal).FromMaybe(false); - } - g_httpDynamicWaiters.erase(ws); - } - g_modulesInFlight.erase(key); - return scope.Escape(resolver->GetPromise()); - } + ResolveHttpDynamicWaiters(isolate, context, key, existing); + return scope.Escape(resolver->GetPromise()); } // Always resolve with namespace for cached modules; JS side will read default resolver->Resolve(context, existing->GetModuleNamespace()).FromMaybe(false); @@ -2076,35 +3214,54 @@ static bool IsDocumentsPath(const std::string& path) { } // mark in-flight before starting network fetch g_modulesInFlight.insert(key); - std::string body; std::string ct; int status = 0; - if (HttpFetchText(normalizedSpec, body, ct, status) && !body.empty()) { - v8::MaybeLocal modMaybe = CompileModuleFromSourceRegisterFirst(isolate, context, body, key); + g_httpDynamicWaiters[key].emplace_back(isolate, resolver); + // Permanent observability: surface fresh fetches so we can confirm + // that post-invalidation, the next dynamic import does NOT re-use + // the cache and DOES go to the network. Filtered to component + // shapes to avoid vendor-chunk noise. Verbose-gated. + if (IsScriptLoadingLogEnabled() && + (key.find("ns/m/") != std::string::npos || key.find(".component") != std::string::npos)) { + Log(@"[ns-hmr][ios-dyn-cache] FRESH-FETCH %s", key.c_str()); + } + v8::MaybeLocal modMaybe = LoadHttpModuleForUrl(isolate, context, normalizedSpec); + if (!modMaybe.IsEmpty()) { v8::Local mod; if (modMaybe.ToLocal(&mod)) { + if (mod->GetStatus() == v8::Module::kUninstantiated && + !mod->InstantiateModule(context, &ResolveModuleCallback).FromMaybe(false)) { + RemoveModuleFromRegistry(key); + RejectHttpDynamicWaiters( + isolate, context, key, + v8::Exception::Error( + tns::ToV8String(isolate, "Instantiation failed (http-loader)"))); + return scope.Escape(resolver->GetPromise()); + } + + if (IsModuleEvaluationInProgress(mod->GetStatus())) { + if (IsScriptLoadingLogEnabled()) { + Log(@"[dyn-import][http-loader] waiting on existing evaluation for %s status=%s", + key.c_str(), ModuleStatusToString(mod->GetStatus())); + } + return scope.Escape(resolver->GetPromise()); + } + // Evaluate once compiled so that namespace is valid for dynamic import resolution if (mod->GetStatus() != v8::Module::kEvaluated) { v8::Local evalResult; if (!mod->Evaluate(context).ToLocal(&evalResult)) { // Remove broken registration and reject RemoveModuleFromRegistry(key); - // Reject all waiters - auto ws = g_httpDynamicWaiters.find(key); - if (ws != g_httpDynamicWaiters.end()) { - for (auto &res : ws->second) { - v8::Local r = res.Get(isolate); - if (!r.IsEmpty()) r->Reject(context, v8::Exception::Error(tns::ToV8String(isolate, "Evaluation failed (http-loader)"))).FromMaybe(false); - } - g_httpDynamicWaiters.erase(ws); - } - resolver->Reject(context, v8::Exception::Error(tns::ToV8String(isolate, "Evaluation failed (http-loader)"))).FromMaybe(false); - g_modulesInFlight.erase(key); + RejectHttpDynamicWaiters( + isolate, context, key, + v8::Exception::Error( + tns::ToV8String(isolate, "Evaluation failed (http-loader)"))); return scope.Escape(resolver->GetPromise()); } // If Evaluate returned a Promise (top-level await), wait until it settles before resolving if (!evalResult.IsEmpty() && evalResult->IsPromise()) { v8::Local p = evalResult.As(); - struct EvalWaitData2 { std::string key; v8::Global ctx; v8::Global mod; v8::Global current; }; - auto* data2 = new EvalWaitData2{ key, v8::Global(isolate, context), v8::Global(isolate, mod), v8::Global(isolate, resolver) }; + struct EvalWaitData2 { std::string key; v8::Global ctx; v8::Global mod; }; + auto* data2 = new EvalWaitData2{ key, v8::Global(isolate, context), v8::Global(isolate, mod) }; auto onFulfilled2 = [](const v8::FunctionCallbackInfo& info) { v8::Isolate* iso = info.GetIsolate(); v8::HandleScope hs(iso); @@ -2113,19 +3270,7 @@ static bool IsDocumentsPath(const std::string& path) { v8::Local ctx = d->ctx.Get(iso); std::string keyLocal = d->key; v8::Local modLocal = d->mod.Get(iso); - auto ws = g_httpDynamicWaiters.find(keyLocal); - v8::Local ns = modLocal->GetModuleNamespace(); - if (ws != g_httpDynamicWaiters.end()) { - for (auto &res : ws->second) { - v8::Local r = res.Get(iso); - if (!r.IsEmpty()) r->Resolve(ctx, ns).FromMaybe(false); - } - g_httpDynamicWaiters.erase(ws); - } - // Also resolve the current resolver in case it wasn't part of waiters - v8::Local cur = d->current.Get(iso); - if (!cur.IsEmpty()) cur->Resolve(ctx, ns).FromMaybe(false); - g_modulesInFlight.erase(keyLocal); + ResolveHttpDynamicWaiters(iso, ctx, keyLocal, modLocal); delete d; }; auto onRejected2 = [](const v8::FunctionCallbackInfo& info) { @@ -2140,17 +3285,7 @@ static bool IsDocumentsPath(const std::string& path) { v8::String::Utf8Value r(iso, reason); if (*r) { Log(@"[dyn-import][http-loader][tla] rejected: %s", *r); } } - auto ws = g_httpDynamicWaiters.find(keyLocal); - if (ws != g_httpDynamicWaiters.end()) { - for (auto &res : ws->second) { - v8::Local r = res.Get(iso); - if (!r.IsEmpty()) r->Reject(ctx, reason).FromMaybe(false); - } - g_httpDynamicWaiters.erase(ws); - } - v8::Local cur = d->current.Get(iso); - if (!cur.IsEmpty()) cur->Reject(ctx, reason).FromMaybe(false); - g_modulesInFlight.erase(keyLocal); + RejectHttpDynamicWaiters(iso, ctx, keyLocal, reason); delete d; }; v8::Local thenFulfillTpl2 = v8::FunctionTemplate::New(isolate, onFulfilled2, v8::External::New(isolate, data2)); @@ -2161,35 +3296,14 @@ static bool IsDocumentsPath(const std::string& path) { return scope.Escape(resolver->GetPromise()); } } - // Do not verify/read default here; JS side handles default access safely. - // Resolve all waiters including current - // Resolve with the module namespace; JS will read .default when needed - v8::Local resultVal = mod->GetModuleNamespace(); - auto ws = g_httpDynamicWaiters.find(key); - if (ws != g_httpDynamicWaiters.end()) { - for (auto &res : ws->second) { - v8::Local r = res.Get(isolate); - if (!r.IsEmpty()) r->Resolve(context, resultVal).FromMaybe(false); - } - g_httpDynamicWaiters.erase(ws); - } - resolver->Resolve(context, resultVal).FromMaybe(false); - g_modulesInFlight.erase(key); + ResolveHttpDynamicWaiters(isolate, context, key, mod); return scope.Escape(resolver->GetPromise()); } - } else if (IsScriptLoadingLogEnabled()) { - Log(@"[dyn-import][http-loader] fetch failed %s status=%d", normalizedSpec.c_str(), status); } // On fetch/compile miss: clean inflight and reject queued - auto ws = g_httpDynamicWaiters.find(key); - if (ws != g_httpDynamicWaiters.end()) { - for (auto &res : ws->second) { - v8::Local r = res.Get(isolate); - if (!r.IsEmpty()) r->Reject(context, v8::Exception::Error(tns::ToV8String(isolate, "HTTP fetch/compile failed"))).FromMaybe(false); - } - g_httpDynamicWaiters.erase(ws); - } - g_modulesInFlight.erase(key); + RejectHttpDynamicWaiters( + isolate, context, key, + v8::Exception::Error(tns::ToV8String(isolate, "HTTP fetch/compile failed"))); } // Attempt to resolve relative specs against the referrer's resource URL if available. @@ -2245,6 +3359,9 @@ static bool IsDocumentsPath(const std::string& path) { Log(@"[dyn-import][resolver-call] raw=%s normalized=%s adjusted=%s", rawSpec.c_str(), normalizedSpec.c_str(), cAdj); } + v8::String::Utf8Value adjustedSpecUtf8(isolate, adjustedSpecifier); + std::string adjustedRegistryKey = + *adjustedSpecUtf8 ? CanonicalizeRegistryKey(*adjustedSpecUtf8) : std::string(); if (maybeModule.IsEmpty()) { if (resolveTc.HasCaught()) { // Reject the promise with the thrown exception so callers don't hang @@ -2303,12 +3420,25 @@ static bool IsDocumentsPath(const std::string& path) { if (!again.ToLocal(&mod2)) { res->Reject(ctx, v8::Exception::Error(tns::ToV8String(isolateInner, "Module still unresolved after fetch"))).FromMaybe(false); } else { + v8::String::Utf8Value specUtf8Inner(isolateInner, specLocal); + std::string retryKey = + *specUtf8Inner ? CanonicalizeRegistryKey(*specUtf8Inner) : std::string(); if (mod2->GetStatus() == v8::Module::kUninstantiated) { if (!mod2->InstantiateModule(ctx, &ResolveModuleCallback).FromMaybe(false)) { res->Reject(ctx, v8::Exception::Error(tns::ToV8String(isolateInner, "Instantiate failed after fetch"))).FromMaybe(false); delete d; return; } } + if (IsModuleEvaluationInProgress(mod2->GetStatus())) { + if (QueueModuleWaiterIfInFlight(isolateInner, retryKey, mod2, res)) { + delete d; + return; + } + + res->Resolve(ctx, mod2->GetModuleNamespace()).FromMaybe(false); + delete d; + return; + } if (mod2->GetStatus() != v8::Module::kEvaluated) { if (mod2->Evaluate(ctx).IsEmpty()) { res->Reject(ctx, v8::Exception::Error(tns::ToV8String(isolateInner, "Evaluation failed after fetch"))).FromMaybe(false); @@ -2373,6 +3503,20 @@ static bool IsDocumentsPath(const std::string& path) { } } + if (IsModuleEvaluationInProgress(module->GetStatus())) { + if (QueueModuleWaiterIfInFlight(isolate, adjustedRegistryKey, module, resolver)) { + return scope.Escape(resolver->GetPromise()); + } + + if (IsScriptLoadingLogEnabled()) { + Log(@"[dyn-import] avoiding re-entrant Evaluate for %s status=%s", + adjustedRegistryKey.empty() ? rawSpec.c_str() : adjustedRegistryKey.c_str(), + ModuleStatusToString(module->GetStatus())); + } + resolver->Resolve(context, module->GetModuleNamespace()).Check(); + return scope.Escape(resolver->GetPromise()); + } + if (module->GetStatus() != v8::Module::kEvaluated) { v8::Local evalResult; if (!module->Evaluate(context).ToLocal(&evalResult)) { diff --git a/NativeScript/runtime/Runtime.h b/NativeScript/runtime/Runtime.h index 92d95dba..4500c8f4 100644 --- a/NativeScript/runtime/Runtime.h +++ b/NativeScript/runtime/Runtime.h @@ -26,7 +26,10 @@ class Runtime { inline CFRunLoopRef RuntimeLoop() { return runtimeLoop_; } - void RunModule(const std::string moduleName); + // Forwards `outErrorMessage` to `ModuleInternal::RunModule(...)` so + // callers can capture the failure cause on a false return. + bool RunModule(const std::string moduleName, + std::string* outErrorMessage = nullptr); void RunScript(const std::string script); diff --git a/NativeScript/runtime/Runtime.mm b/NativeScript/runtime/Runtime.mm index 2de309cc..3e7d34af 100644 --- a/NativeScript/runtime/Runtime.mm +++ b/NativeScript/runtime/Runtime.mm @@ -28,7 +28,7 @@ #include "URLImpl.h" #include "URLPatternImpl.h" #include "URLSearchParamsImpl.h" -#include +#include #include "HMRSupport.h" #include "DevFlags.h" @@ -39,6 +39,25 @@ using namespace std; // Import meta callback to support import.meta.url +// +// `g_moduleRegistry` keys are normalized by `CanonicalizeRegistryKey` +// (in ModuleInternalCallbacks.mm) to one of: +// +// 1. HTTP / HTTPS URL — `http://host:port/path` or `https://...`. +// The URL IS the module identity; +// `import.meta.url` should be the URL verbatim. +// +// 2. Custom scheme — `ns-vendor://...`, `node:fs`, `blob:...`, +// `optional:...`. Synthetic / built-in modules that aren't backed +// by the local filesystem. Their identity is the scheme + body +// itself; `import.meta.url` keeps that string unchanged. +// +// 3. Absolute filesystem path — `/Users/.../app/src/foo.js`. The +// historical production / non-HMR dev shape. Strip the runtime +// base dir to recover the legacy "/app/" shape JS consumers +// have always seen, then prepend `file://` so the result is a +// well-formed URL. +// static void InitializeImportMetaObject(Local context, Local module, Local meta) { Isolate* isolate = context->GetIsolate(); @@ -64,24 +83,32 @@ static void InitializeImportMetaObject(Local context, Local mod modulePath = ""; // Will use fallback path } - // Debug logging - // NSLog(@"[import.meta] Module lookup: found path = %s", - // modulePath.empty() ? "(empty)" : modulePath.c_str()); - // NSLog(@"[import.meta] Registry size: %zu", tns::g_moduleRegistry.size()); + auto hasUrlScheme = [](const std::string& s) -> bool { + if (s.empty()) return false; + size_t colonPos = s.find(':'); + if (colonPos == 0 || colonPos == std::string::npos) return false; + size_t slashPos = s.find('/'); + if (slashPos != std::string::npos && slashPos < colonPos) return false; + for (size_t i = 0; i < colonPos; i++) { + char c = s[i]; + const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '+' || c == '-' || c == '.'; + if (!ok) return false; + } + return true; + }; - // Convert file path to file:// URL + // Compute import.meta.url. std::string moduleUrl; - if (!modulePath.empty()) { - // Remove base directory and create file:// URL + if (modulePath.empty()) { + moduleUrl = "file:///app/"; + } else if (hasUrlScheme(modulePath)) { + moduleUrl = modulePath; + } else { std::string base = tns::ReplaceAll(modulePath, RuntimeConfig.BaseDir, ""); moduleUrl = "file://" + base; - } else { - // Fallback URL if module not found in registry - moduleUrl = "file:///app/"; } - // NSLog(@"[import.meta] Final URL: %s", moduleUrl.c_str()); - Local url = String::NewFromUtf8(isolate, moduleUrl.c_str(), NewStringType::kNormal).ToLocalChecked(); @@ -91,17 +118,42 @@ static void InitializeImportMetaObject(Local context, Local mod url) .Check(); - // Add import.meta.dirname support (extract directory from path) + // Compute import.meta.dirname. + // + // Spec (Node.js): `import.meta.dirname` is the OS-path of the + // directory containing the module — equivalent to `path.dirname( + // fileURLToPath(import.meta.url))`. It only makes sense for modules + // backed by the local filesystem. + // + // For URL-backed modules (HTTP, ns-vendor, blob, etc.) there is no + // filesystem directory. We return the URL with the final segment + // stripped — a best-effort answer that's stable across cycles and + // useful for log lines / source maps. Consumers that genuinely need + // a filesystem path should already be guarding on `meta.url`'s + // scheme before using `meta.dirname`. std::string dirname; - if (!modulePath.empty()) { + if (modulePath.empty()) { + dirname = "/app"; + } else if (hasUrlScheme(modulePath)) { + size_t schemeEnd = modulePath.find("://"); + size_t pathStart = (schemeEnd == std::string::npos) ? std::string::npos + : modulePath.find('/', schemeEnd + 3); + size_t lastSlash = modulePath.find_last_of('/'); + if (pathStart != std::string::npos && lastSlash != std::string::npos && + lastSlash > pathStart) { + dirname = modulePath.substr(0, lastSlash); + } else { + // No path beyond the host (`http://host`) or scheme without `//` + // (`node:fs`, `blob:abc`). Keep the identity intact. + dirname = modulePath; + } + } else { size_t lastSlash = modulePath.find_last_of("/\\"); if (lastSlash != std::string::npos) { dirname = modulePath.substr(0, lastSlash); } else { dirname = "/app"; // fallback } - } else { - dirname = "/app"; // fallback } Local dirnameStr = @@ -198,6 +250,8 @@ void DisposeIsolateWhenPossible(Isolate* isolate) { } this->isolate_->TerminateExecution(); + // TODO: fix race condition on workers where a queue can leak (maybe calling Terminate before + // Initialize?) Caches::Workers->ForEach([currentIsolate](int& key, std::shared_ptr& value) { auto childWorkerWrapper = static_cast(value->UserData()); if (childWorkerWrapper->GetMainIsolate() == currentIsolate) { @@ -209,14 +263,44 @@ void DisposeIsolateWhenPossible(Isolate* isolate) { { v8::Locker lock(isolate_); - // Clear module registry before disposing other handles - // This prevents crashes during g_moduleRegistry cleanup - extern std::unordered_map> g_moduleRegistry; + // Clear module registry before disposing other handles. + // This prevents crashes during g_moduleRegistry cleanup. The registry is + // `thread_local` (each NS isolate has its own per-thread map; see + // ModuleInternalCallbacks.mm for rationale), so this loop walks ONLY the + // entries that this destructor's thread/isolate created. for (auto& kv : g_moduleRegistry) { kv.second.Reset(); } g_moduleRegistry.clear(); + // Clear HMR + import-map globals (`g_importMap`, `g_hotData`, + // `g_hotAccept`, `g_hotDispose`, `g_hotPrune`, `g_hotEventListeners`, + // `g_hotDeclined`, `g_vendorModuleCache`, etc.) before isolate disposal. + // These hold v8::Global handles that would crash during static destructor + // cleanup if the isolate is already torn down. + // + // CRITICAL: these globals are PROCESS-WIDE, not per-isolate. They live + // in the main isolate's address space but every Runtime destructor would + // clear them. That's wrong for worker-isolate teardown: when a worker + // dies (e.g. via `__nsTerminateAllWorkers` during an HMR cycle), its + // Runtime destructor MUST NOT wipe the main isolate's import map and + // hot-state — doing so silently breaks the next HMR cycle's bare- + // specifier resolution (vendor packages fall back to filesystem and + // fail with `Cannot find module @scope/pkg`). + // + // Worker isolates have their own `g_moduleRegistry` (thread_local, + // cleared above), but they SHARE the static globals with the main + // isolate. So we gate this cleanup on "this is the main isolate" — + // worker teardown leaves the shared globals intact and the main + // isolate continues serving HMR cycles uninterrupted. Real + // process-teardown still routes through the main isolate's + // destructor, so the cleanup eventually fires. + if (!IsRuntimeWorker()) { + tns::CleanupHMRGlobals(); + tns::CleanupImportMapGlobals(); + tns::ResetActiveDevSession(); + } + DisposerPHV phv(isolate_); isolate_->VisitHandlesWithClassIds(&phv); @@ -337,76 +421,15 @@ void DisposeIsolateWhenPossible(Isolate* isolate) { PromiseProxy::Init(context); Console::Init(context); WeakRef::Init(context); + + // Install every JS-callable HMR + dev-session global the + // @nativescript/vite HMR client and deterministic dev-session bootstrap + // depend on. Previously inline lambdas here (~650 lines); see + // `InitializeHmrDevGlobals` in HMRSupport.mm for the full list and the + // try/catch-gated dev-only entry points. + tns::InitializeHmrDevGlobals(isolate, context); - auto blob_methods = R"js( - const BLOB_STORE = new Map(); - URL.createObjectURL = function (object, options = null) { - try { - if (object instanceof Blob || object instanceof File) { - const id = NSUUID.UUID().UUIDString.toLowerCase(); - const ret = `blob:nativescript/${id}`; - BLOB_STORE.set(ret, { - blob: object, - type: object?.type, - ext: options?.ext, - }); - return ret; - } - } catch (error) { - return null; - } - return null; - }; - URL.revokeObjectURL = function (url) { - BLOB_STORE.delete(url); - }; - const InternalAccessor = class {}; - InternalAccessor.getData = function (url) { - return BLOB_STORE.get(url); - }; - URL.InternalAccessor = InternalAccessor; - Object.defineProperty(URL.prototype, 'searchParams', { - get() { - if (this._searchParams == null) { - this._searchParams = new URLSearchParams(this.search); - Object.defineProperty(this._searchParams, '_url', { - enumerable: false, - writable: false, - value: this, - }); - this._searchParams._append = this._searchParams.append; - this._searchParams.append = function (name, value) { - this._append(name, value); - this._url.search = this.toString(); - }; - this._searchParams._delete = this._searchParams.delete; - this._searchParams.delete = function (name) { - this._delete(name); - this._url.search = this.toString(); - }; - this._searchParams._set = this._searchParams.set; - this._searchParams.set = function (name, value) { - this._set(name, value); - this._url.search = this.toString(); - }; - this._searchParams._sort = this._searchParams.sort; - this._searchParams.sort = function () { - this._sort(); - this._url.search = this.toString(); - }; - } - return this._searchParams; - }, - }); - )js"; - - v8::Local script; - auto done = v8::Script::Compile(context, ToV8String(isolate, blob_methods)).ToLocal(&script); - - v8::Local outVal; - if (done) { - done = script->Run(context).ToLocal(&outVal); - } + URLImpl::InstallBlobMethods(context); this->moduleInternal_ = std::make_unique(context); @@ -435,11 +458,12 @@ void DisposeIsolateWhenPossible(Isolate* isolate) { this->moduleInternal_->RunModule(isolate, "./"); } -void Runtime::RunModule(const std::string moduleName) { +bool Runtime::RunModule(const std::string moduleName, + std::string* outErrorMessage) { Isolate* isolate = this->GetIsolate(); Isolate::Scope isolate_scope(isolate); HandleScope handle_scope(isolate); - this->moduleInternal_->RunModule(isolate, moduleName); + return this->moduleInternal_->RunModule(isolate, moduleName, outErrorMessage); } void Runtime::RunScript(const std::string script) { diff --git a/NativeScript/runtime/URLImpl.cpp b/NativeScript/runtime/URLImpl.cpp index a8c322d9..994197cc 100644 --- a/NativeScript/runtime/URLImpl.cpp +++ b/NativeScript/runtime/URLImpl.cpp @@ -18,6 +18,88 @@ void URLImpl::Init(v8::Isolate* isolate, v8::Local globalTem globalTemplate->Set(urlPropertyName, URLTemplate); } +void URLImpl::InstallBlobMethods(v8::Local context) { + v8::Isolate* isolate = context->GetIsolate(); + // URL.createObjectURL/revokeObjectURL and blob URL registry + // Blob URLs have the format: blob:/ + // We use blob:nativescript/ as NativeScript's origin identifier + auto blob_methods = R"js( + const BLOB_STORE = new Map(); + URL.createObjectURL = function (object, options = null) { + try { + if (object instanceof Blob || object instanceof File) { + const id = NSUUID.UUID().UUIDString.toLowerCase(); + const ret = `blob:nativescript/${id}`; + BLOB_STORE.set(ret, { + blob: object, + type: object?.type, + ext: options?.ext, + }); + return ret; + } + } catch (error) { + return null; + } + return null; + }; + URL.revokeObjectURL = function (url) { + BLOB_STORE.delete(url); + }; + const InternalAccessor = class {}; + InternalAccessor.getData = function (url) { + return BLOB_STORE.get(url); + }; + // Get the text content directly from a blob URL (for HMR) + InternalAccessor.getText = async function (url) { + const data = BLOB_STORE.get(url); + if (!data || !data.blob) return null; + return await data.blob.text(); + }; + URL.InternalAccessor = InternalAccessor; + Object.defineProperty(URL.prototype, 'searchParams', { + get() { + if (this._searchParams == null) { + this._searchParams = new URLSearchParams(this.search); + Object.defineProperty(this._searchParams, '_url', { + enumerable: false, + writable: false, + value: this, + }); + this._searchParams._append = this._searchParams.append; + this._searchParams.append = function (name, value) { + this._append(name, value); + this._url.search = this.toString(); + }; + this._searchParams._delete = this._searchParams.delete; + this._searchParams.delete = function (name) { + this._delete(name); + this._url.search = this.toString(); + }; + this._searchParams._set = this._searchParams.set; + this._searchParams.set = function (name, value) { + this._set(name, value); + this._url.search = this.toString(); + }; + this._searchParams._sort = this._searchParams.sort; + this._searchParams.sort = function () { + this._sort(); + this._url.search = this.toString(); + }; + } + return this._searchParams; + }, + }); + )js"; + + v8::Local script; + auto compiled = v8::Script::Compile(context, ToV8String(isolate, blob_methods)).ToLocal(&script); + + if (compiled) { + v8::Local outVal; + (void)script->Run(context).ToLocal(&outVal); + } +} + URLImpl *URLImpl::GetPointer(v8::Local object) { auto ptr = object->GetAlignedPointerFromInternalField(0); if (ptr == nullptr) { diff --git a/NativeScript/runtime/URLImpl.h b/NativeScript/runtime/URLImpl.h index 4a77cb2a..0e1b5237 100644 --- a/NativeScript/runtime/URLImpl.h +++ b/NativeScript/runtime/URLImpl.h @@ -17,7 +17,16 @@ namespace tns { url_aggregator *GetURL(); static void Init(v8::Isolate* isolate, v8::Local globalTemplate); - + + // Compiles and runs the JS polyfill that installs + // `URL.createObjectURL` / `URL.revokeObjectURL`, the in-process blob + // registry (`URL.InternalAccessor`) used by the HMR loader, and the + // `URL.prototype.searchParams` accessor. Must be called once per + // realm AFTER `Init()` has installed the `URL` constructor and AFTER + // `URLSearchParamsImpl::Init()` has installed `URLSearchParams`. + // Equivalent to running the same script literal that used to live + // inline in `Runtime::Init`; behavior is bit-for-bit identical. + static void InstallBlobMethods(v8::Local context); static URLImpl *GetPointer(v8::Local object); diff --git a/NativeScript/runtime/Worker.h b/NativeScript/runtime/Worker.h index f1663336..3f3a93ed 100644 --- a/NativeScript/runtime/Worker.h +++ b/NativeScript/runtime/Worker.h @@ -15,6 +15,13 @@ class Worker { static void ConstructorCallback(const v8::FunctionCallbackInfo& info); static void PostMessageCallback(const v8::FunctionCallbackInfo& info); static void TerminateCallback(const v8::FunctionCallbackInfo& info); + // HMR-oriented helper: terminate every worker the runtime currently knows + // about, snapshotted from `Caches::Workers`. Registered on the main + // isolate as `globalThis.__nsTerminateAllWorkers` (returns the count of + // workers terminated, as a number). Worker threads do NOT receive this + // global — terminating workers from inside a worker would let a stuck + // worker take down its peers. + static void TerminateAllWorkersCallback(const v8::FunctionCallbackInfo& info); static void OnMessageCallback(v8::Isolate* isolate, v8::Local receiver, std::shared_ptr message); static void PostMessageToMainCallback(const v8::FunctionCallbackInfo& info); static void CloseWorkerCallback(const v8::FunctionCallbackInfo& info); diff --git a/NativeScript/runtime/Worker.mm b/NativeScript/runtime/Worker.mm index b68c8236..4a00b5c0 100644 --- a/NativeScript/runtime/Worker.mm +++ b/NativeScript/runtime/Worker.mm @@ -29,6 +29,27 @@ Local closeTemplate = FunctionTemplate::New(isolate, Worker::CloseWorkerCallback); globalTemplate->Set(tns::ToV8String(isolate, "close"), closeTemplate); + } else { + // Main-thread-only: HMR helper that terminates every live worker. + // + // Exposes `globalThis.__nsTerminateAllWorkers()` so HMR runtimes + // (e.g. @nativescript/vite) can drop every worker in a single call + // before re-bootstrapping the JS app. Without this, every HMR cycle + // that re-runs a constructor (or any JS scope that creates a + // worker) leaks a live worker into the iOS dispatch table — workers + // pile up across cycles, all delivering postMessages in parallel. + // + // The runtime is the single source of truth here: `Caches::Workers` + // already tracks every worker for lifecycle reasons, so the JS side + // doesn't need to mirror that state. Worker threads do NOT receive + // this global — a stuck worker shouldn't be able to take down its + // peers. See `Worker::TerminateAllWorkersCallback` for the snapshot + // semantics that keep iteration safe across concurrent worker + // teardown. + Local terminateAllTemplate = + FunctionTemplate::New(isolate, Worker::TerminateAllWorkersCallback); + globalTemplate->Set(tns::ToV8String(isolate, "__nsTerminateAllWorkers"), + terminateAllTemplate); } // Register functions in the main thread Local workerFuncTemplate = FunctionTemplate::New(isolate, ConstructorCallback); @@ -372,6 +393,59 @@ throw NativeScriptException( worker->Terminate(); } +void Worker::TerminateAllWorkersCallback(const FunctionCallbackInfo& info) { + // Snapshot the current worker set BEFORE terminating, holding a + // shared_ptr to each `WorkerState` so the underlying `WorkerWrapper` + // can't be deleted out from under us while we iterate. + // + // `Caches::Workers` is a `ConcurrentMap` that protects insert/remove + // with a mutex, but `ForEach` holds the lock for its full duration — + // any concurrent worker-thread teardown that tries to call `Remove` + // would block. To avoid stalling teardown (and to keep `Terminate` + // calls outside the map's critical section), we copy the values into + // a local vector and iterate it after the lock is released. + // + // `WorkerWrapper::Terminate()` is atomic and idempotent — it uses + // `isTerminating_.exchange(true)` to guard against double-termination + // — so even if a worker is mid-shutdown when we call it, the call is + // a no-op. We still wrap each call in try/catch as defence-in-depth: + // a single misbehaving worker should never abort the HMR cycle that + // triggered this callback. + std::vector> snapshot; + Caches::Workers->ForEach([&snapshot](int& /* id */, + std::shared_ptr& state) -> bool { + if (state) { + snapshot.push_back(state); + } + return false; // continue iteration over every entry + }); + + int32_t terminatedCount = 0; + for (auto& state : snapshot) { + auto* worker = static_cast(state->UserData()); + if (worker == nullptr) { + continue; + } + if (!worker->IsRunning() || worker->IsClosing()) { + // Already torn down or in the process of closing. Counting these + // would inflate the diagnostic count returned to JS; skip. + continue; + } + try { + worker->Terminate(); + ++terminatedCount; + } catch (...) { + // Swallow per-worker failures. The HMR caller cares about + // "terminate everything you can" semantics, not strict + // all-or-nothing. + } + } + + // Return the count of workers we actually terminated so the JS HMR + // client can include it in diagnostic logs. + info.GetReturnValue().Set(terminatedCount); +} + Local Worker::Serialize(Isolate* isolate, Local value, Local& error) { Local context = isolate->GetCurrentContext(); Local objTemplate = ObjectTemplate::New(isolate); diff --git a/TestRunner/app/tests/HttpEsmLoaderTests.js b/TestRunner/app/tests/HttpEsmLoaderTests.js index d12728ae..5ea04a6b 100644 --- a/TestRunner/app/tests/HttpEsmLoaderTests.js +++ b/TestRunner/app/tests/HttpEsmLoaderTests.js @@ -2,6 +2,45 @@ // Test the dev-only HTTP ESM loader functionality for fetching modules remotely describe("HTTP ESM Loader", function() { + + function formatError(e) { + try { + if (!e) return "(no error)"; + if (e instanceof Error) return e.message; + if (typeof e === "string") return e; + if (e && typeof e.message === "string") return e.message; + return JSON.stringify(e); + } catch (_) { + return String(e); + } + } + + function withTimeout(promise, ms, label) { + return new Promise(function(resolve, reject) { + var timer = setTimeout(function() { + reject(new Error("Timeout after " + ms + "ms" + (label ? ": " + label : ""))); + }, ms); + + promise.then(function(value) { + clearTimeout(timer); + resolve(value); + }).catch(function(err) { + clearTimeout(timer); + reject(err); + }); + }); + } + + function getHostOrigin() { + try { + var reportUrl = NSProcessInfo.processInfo.environment.objectForKey("REPORT_BASEURL"); + if (!reportUrl) return null; + var u = new URL(String(reportUrl)); + return u.origin; + } catch (e) { + return null; + } + } describe("URL Resolution", function() { it("should handle relative imports", function(done) { @@ -125,10 +164,12 @@ describe("HTTP ESM Loader", function() { }); it("should handle network timeouts", function(done) { - // Attempt to import from an unreachable address to test timeout - // 192.0.2.1 is a TEST-NET-1 address reserved by RFC 5737 for documentation and testing purposes. - // It is intentionally used here to trigger a network timeout scenario. - import("http://192.0.2.1:5173/timeout-test.js").then(function(module) { + // Prefer the local XCTest-hosted HTTP server (when available) to avoid ATS restrictions + // and make this test deterministic. + var origin = getHostOrigin(); + var spec = origin ? (origin + "/esm/timeout.mjs?delayMs=6500") : "https://192.0.2.1:5173/timeout-test.js"; + + import(spec).then(function(module) { fail("Should not have succeeded for unreachable server"); done(); }).catch(function(error) { @@ -185,6 +226,280 @@ describe("HTTP ESM Loader", function() { }); }); }); + + describe("HMR hot.data", function () { + it("should expose import.meta.hot.data and stable API", function (done) { + var origin = getHostOrigin(); + var specs = origin + ? [origin + "/esm/hmr/hot-data-ext.mjs", origin + "/esm/hmr/hot-data-ext.js"] + : ["~/tests/esm/hmr/hot-data-ext.mjs"]; + + withTimeout(Promise.all(specs.map(function (s) { return import(s); })), 5000, "import hot-data test modules") + .then(function (mods) { + var mjs = mods[0]; + var apiMjs = mjs && typeof mjs.testHotApi === "function" ? mjs.testHotApi() : null; + + // In release builds import.meta.hot is stripped; skip these assertions. + if (!(apiMjs && apiMjs.hasHot)) { + pending("import.meta.hot not available (likely release build)"); + done(); + return; + } + + expect(apiMjs.ok).toBe(true); + if (mods.length > 1) { + var js = mods[1]; + var apiJs = js && typeof js.testHotApi === "function" ? js.testHotApi() : null; + expect(apiJs && apiJs.ok).toBe(true); + } + done(); + }) + .catch(function (error) { + fail("Expected hot-data test modules to import: " + formatError(error)); + done(); + }); + }); + + it("should share hot.data across .mjs and .js variants", function (done) { + var origin = getHostOrigin(); + if (!origin) { + pending("REPORT_BASEURL not set; cannot import .js as ESM in this harness"); + done(); + return; + } + + withTimeout(Promise.all([ + import(origin + "/esm/hmr/hot-data-ext.mjs"), + import(origin + "/esm/hmr/hot-data-ext.js"), + ]), 5000, "import .mjs/.js hot-data modules") + .then(function (mods) { + var mjs = mods[0]; + var js = mods[1]; + + var hotMjs = mjs && typeof mjs.getHot === "function" ? mjs.getHot() : null; + var hotJs = js && typeof js.getHot === "function" ? js.getHot() : null; + if (!hotMjs || !hotJs) { + pending("import.meta.hot not available (likely release build)"); + done(); + return; + } + + var dataMjs = mjs.getHotData(); + var dataJs = js.getHotData(); + expect(dataMjs).toBeDefined(); + expect(dataJs).toBeDefined(); + + var token = "tok_" + Date.now() + "_" + Math.random(); + mjs.setHotValue(token); + expect(js.getHotValue()).toBe(token); + + // Canonical hot key strips common script extensions, so these should share identity. + expect(dataMjs).toBe(dataJs); + done(); + }) + .catch(function (error) { + fail("Expected hot.data sharing assertions to succeed: " + formatError(error)); + done(); + }); + }); + + it("should share hot.data across canonical, live-tagged, and boot-tagged /ns/m URLs", function (done) { + var origin = getHostOrigin(); + if (!origin) { + pending("REPORT_BASEURL not set; cannot import host HTTP HMR aliases in this harness"); + done(); + return; + } + + var canonicalUrl = origin + "/ns/m/esm/hmr/hot-data-ext.mjs"; + var liveTaggedUrl = origin + "/ns/m/__ns_hmr__/live/esm/hmr/hot-data-ext.js"; + var bootTaggedUrl = origin + "/ns/m/__ns_boot__/b1/__ns_hmr__/live/esm/hmr/hot-data-ext.mjs"; + + withTimeout(Promise.all([ + import(canonicalUrl), + import(liveTaggedUrl), + import(bootTaggedUrl), + ]), 5000, "import /ns/m hot-data aliases") + .then(function (mods) { + var canonical = mods[0]; + var liveTagged = mods[1]; + var bootTagged = mods[2]; + + var hotCanonical = canonical && typeof canonical.getHot === "function" ? canonical.getHot() : null; + var hotLive = liveTagged && typeof liveTagged.getHot === "function" ? liveTagged.getHot() : null; + var hotBoot = bootTagged && typeof bootTagged.getHot === "function" ? bootTagged.getHot() : null; + if (!hotCanonical || !hotLive || !hotBoot) { + pending("import.meta.hot not available (likely release build)"); + done(); + return; + } + + var dataCanonical = canonical.getHotData(); + var dataLive = liveTagged.getHotData(); + var dataBoot = bootTagged.getHotData(); + expect(dataCanonical).toBeDefined(); + expect(dataLive).toBeDefined(); + expect(dataBoot).toBeDefined(); + + var token = "hmr_alias_" + Date.now() + "_" + Math.random(); + canonical.setHotValue(token); + expect(liveTagged.getHotValue()).toBe(token); + expect(bootTagged.getHotValue()).toBe(token); + expect(dataCanonical).toBe(dataLive); + expect(dataCanonical).toBe(dataBoot); + done(); + }) + .catch(function (error) { + fail("Expected /ns/m hot-data alias imports to share state: " + formatError(error)); + done(); + }); + }); + + it("should share hot.data across versioned and canonical /ns/core bridge URLs", function (done) { + var origin = getHostOrigin(); + if (!origin) { + pending("REPORT_BASEURL not set; cannot import host HTTP bridge aliases in this harness"); + done(); + return; + } + + withTimeout(Promise.all([ + import(origin + "/ns/core"), + import(origin + "/ns/core/42"), + ]), 5000, "import /ns/core bridge aliases") + .then(function (mods) { + var canonical = mods[0]; + var versioned = mods[1]; + + var hotCanonical = canonical && typeof canonical.getHot === "function" ? canonical.getHot() : null; + var hotVersioned = versioned && typeof versioned.getHot === "function" ? versioned.getHot() : null; + if (!hotCanonical || !hotVersioned) { + pending("import.meta.hot not available (likely release build)"); + done(); + return; + } + + var dataCanonical = canonical.getHotData(); + var dataVersioned = versioned.getHotData(); + expect(dataCanonical).toBeDefined(); + expect(dataVersioned).toBeDefined(); + + var token = "core_bridge_" + Date.now() + "_" + Math.random(); + canonical.setHotValue(token); + expect(versioned.getHotValue()).toBe(token); + expect(dataCanonical).toBe(dataVersioned); + done(); + }) + .catch(function (error) { + fail("Expected /ns/core bridge aliases to share hot.data: " + formatError(error)); + done(); + }); + }); + }); + + describe("URL Key Canonicalization", function () { + it("preserves query for non-dev/public URLs", function (done) { + var origin = getHostOrigin(); + if (!origin) { + pending("REPORT_BASEURL not set; skipping host HTTP tests"); + done(); + return; + } + + var u1 = origin + "/esm/query.mjs?v=1"; + var u2 = origin + "/esm/query.mjs?v=2"; + + withTimeout(import(u1), 5000, "import " + u1) + .then(function (m1) { + return withTimeout(import(u2), 5000, "import " + u2).then(function (m2) { + expect(m1.query).toContain("v=1"); + expect(m2.query).toContain("v=2"); + expect(m1.query).not.toBe(m2.query); + done(); + }); + }) + .catch(function (error) { + fail("Expected host HTTP module imports to succeed: " + formatError(error)); + done(); + }); + }); + + it("drops t/v/import for NativeScript dev endpoints", function (done) { + var origin = getHostOrigin(); + if (!origin) { + pending("REPORT_BASEURL not set; skipping host HTTP tests"); + done(); + return; + } + + var u1 = origin + "/ns/m/query.mjs?v=1"; + var u2 = origin + "/ns/m/query.mjs?v=2"; + + withTimeout(import(u1), 5000, "import " + u1) + .then(function (m1) { + return withTimeout(import(u2), 5000, "import " + u2).then(function (m2) { + // With cache-buster normalization, both imports should map to the same cache key. + // The second import should reuse the first evaluated module. + expect(m2.evaluatedAt).toBe(m1.evaluatedAt); + expect(m2.query).toBe(m1.query); + done(); + }); + }) + .catch(function (error) { + fail("Expected dev-endpoint HTTP module imports to succeed: " + formatError(error)); + done(); + }); + }); + + it("sorts query params for NativeScript dev endpoints", function (done) { + var origin = getHostOrigin(); + if (!origin) { + pending("REPORT_BASEURL not set; skipping host HTTP tests"); + done(); + return; + } + + var u1 = origin + "/ns/m/query.mjs?b=2&a=1"; + var u2 = origin + "/ns/m/query.mjs?a=1&b=2"; + + withTimeout(import(u1), 5000, "import " + u1) + .then(function (m1) { + return withTimeout(import(u2), 5000, "import " + u2).then(function (m2) { + expect(m2.evaluatedAt).toBe(m1.evaluatedAt); + expect(m2.query).toBe(m1.query); + done(); + }); + }) + .catch(function (error) { + fail("Expected dev-endpoint HTTP module imports to succeed: " + formatError(error)); + done(); + }); + }); + + it("ignores URL fragments for cache identity", function (done) { + var origin = getHostOrigin(); + if (!origin) { + pending("REPORT_BASEURL not set; skipping host HTTP tests"); + done(); + return; + } + + var u1 = origin + "/esm/query.mjs#one"; + var u2 = origin + "/esm/query.mjs#two"; + + withTimeout(import(u1), 5000, "import " + u1) + .then(function (m1) { + return withTimeout(import(u2), 5000, "import " + u2).then(function (m2) { + expect(m2.evaluatedAt).toBe(m1.evaluatedAt); + done(); + }); + }) + .catch(function (error) { + fail("Expected fragment HTTP module imports to succeed: " + formatError(error)); + done(); + }); + }); + }); }); console.log("HTTP ESM Loader tests loaded"); \ No newline at end of file diff --git a/TestRunner/app/tests/Infrastructure/Jasmine/jasmine-2.0.1/boot.js b/TestRunner/app/tests/Infrastructure/Jasmine/jasmine-2.0.1/boot.js index f2c53e27..78f3ba35 100644 --- a/TestRunner/app/tests/Infrastructure/Jasmine/jasmine-2.0.1/boot.js +++ b/TestRunner/app/tests/Infrastructure/Jasmine/jasmine-2.0.1/boot.js @@ -63,6 +63,15 @@ var TerminalReporter = require('../jasmine-reporters/terminal_reporter').Termina return env.pending(); }, + fail: function(error) { + // Jasmine 2.0 fail() – mark current spec as failed with given message + var message = error; + if (error && typeof error === 'object') { + message = error.message || String(error); + } + throw new Error(message); + }, + spyOn: function(obj, methodName) { return env.spyOn(obj, methodName); }, diff --git a/TestRunner/app/tests/MethodCallsTests.js b/TestRunner/app/tests/MethodCallsTests.js index 6da28679..b30fd981 100644 --- a/TestRunner/app/tests/MethodCallsTests.js +++ b/TestRunner/app/tests/MethodCallsTests.js @@ -678,20 +678,42 @@ describe(module.id, function () { var actual = TNSGetOutput(); expect(actual).toBe('static setBaseProtocolProperty2: calledstatic baseProtocolProperty2 called'); }); - it('Base_BaseProtocolProperty2Optional', function () { + it('Base_InstanceBaseProtocolProperty2Optional', function () { var instance = TNSBaseInterface.alloc().init(); - instance.baseProtocolProperty2Optional = 1; - UNUSED(instance.baseProtocolProperty2Optional); - - var actual = TNSGetOutput(); - expect(actual).toBe('instance setBaseProtocolProperty2Optional: calledinstance baseProtocolProperty2Optional called'); - }); - it('Base_BaseProtocolProperty2Optional', function () { - TNSBaseInterface.baseProtocolProperty2Optional = 1; - UNUSED(TNSBaseInterface.baseProtocolProperty2Optional); - - var actual = TNSGetOutput(); - expect(actual).toBe('static setBaseProtocolProperty2Optional: calledstatic baseProtocolProperty2Optional called'); + if (typeof instance.setBaseProtocolProperty2Optional === 'function') { + instance.setBaseProtocolProperty2Optional(1); + } else { + instance.baseProtocolProperty2Optional = 1; + } + + if (typeof instance.baseProtocolProperty2Optional === 'function') { + UNUSED(instance.baseProtocolProperty2Optional()); + } else { + UNUSED(instance.baseProtocolProperty2Optional); + } + + var actual = TNSGetOutput(); + // Some runtimes may invoke the optional property getter more than once. + expect(actual.indexOf('instance setBaseProtocolProperty2Optional: called')).toBe(0); + expect(actual).toContain('instance baseProtocolProperty2Optional called'); + }); + it('Base_StaticBaseProtocolProperty2Optional', function () { + if (typeof TNSBaseInterface.setBaseProtocolProperty2Optional === 'function') { + TNSBaseInterface.setBaseProtocolProperty2Optional(1); + } else { + TNSBaseInterface.baseProtocolProperty2Optional = 1; + } + + if (typeof TNSBaseInterface.baseProtocolProperty2Optional === 'function') { + UNUSED(TNSBaseInterface.baseProtocolProperty2Optional()); + } else { + UNUSED(TNSBaseInterface.baseProtocolProperty2Optional); + } + + var actual = TNSGetOutput(); + // Some runtimes may invoke the optional property getter more than once. + expect(actual.indexOf('static setBaseProtocolProperty2Optional: called')).toBe(0); + expect(actual).toContain('static baseProtocolProperty2Optional called'); }); it('Base_BaseProperty', function () { var instance = TNSBaseInterface.alloc().init(); diff --git a/TestRunner/app/tests/NodeBuiltinsAndOptionalModulesTests.mjs b/TestRunner/app/tests/NodeBuiltinsAndOptionalModulesTests.mjs index b2259f37..0f82c094 100644 --- a/TestRunner/app/tests/NodeBuiltinsAndOptionalModulesTests.mjs +++ b/TestRunner/app/tests/NodeBuiltinsAndOptionalModulesTests.mjs @@ -2,8 +2,10 @@ describe("Node built-in and optional module resolution", function () { it("provides an in-memory polyfill for node:url", async function () { // Dynamic import to exercise ResolveModuleCallback ESM path. const mod = await import("node:url"); + const modAgain = await import("node:url"); expect(mod).toBeDefined(); + expect(modAgain).toBe(mod); expect(typeof mod.fileURLToPath).toBe("function"); expect(typeof mod.pathToFileURL).toBe("function"); @@ -18,8 +20,10 @@ describe("Node built-in and optional module resolution", function () { it("creates an in-memory placeholder for likely-optional modules", async function () { // Use a name that IsLikelyOptionalModule will treat as optional (no slashes, no extension). const mod = await import("__ns_optional_test_module__"); + const modAgain = await import("__ns_optional_test_module__"); expect(mod).toBeDefined(); + expect(modAgain).toBe(mod); expect(typeof mod.default).toBe("object"); let threw = false; @@ -32,4 +36,81 @@ describe("Node built-in and optional module resolution", function () { } expect(threw).toBe(true); }); + + it("resolves import-map vendor modules through the explicit vendor registry", async function () { + const configureRuntime = globalThis.__nsConfigureDevRuntime || globalThis.__nsConfigureRuntime; + expect(typeof configureRuntime).toBe("function"); + + const previousRegistry = globalThis.__nsVendorRegistry; + const vendorRegistry = new Map(); + globalThis.__nsVendorRegistry = vendorRegistry; + vendorRegistry.set("__ns_test_vendor__", { + default: { source: "vendor-default" }, + namedValue: 7, + makeValue() { + return "vendor-named"; + }, + }); + + try { + configureRuntime({ + importMap: { + imports: { + __ns_test_vendor__: "ns-vendor://__ns_test_vendor__", + }, + }, + }); + + const mod = await import("__ns_test_vendor__"); + const modAgain = await import("__ns_test_vendor__"); + + expect(mod).toBeDefined(); + expect(modAgain).toBe(mod); + expect(mod.default).toEqual({ source: "vendor-default" }); + expect(mod.namedValue).toBe(7); + expect(mod.makeValue()).toBe("vendor-named"); + } finally { + configureRuntime({ importMap: { imports: {} } }); + if (typeof previousRegistry === "undefined") { + delete globalThis.__nsVendorRegistry; + } else { + globalThis.__nsVendorRegistry = previousRegistry; + } + } + }); + + it("reuses blob URL modules across concurrent and repeated imports", async function () { + delete globalThis.__nsBlobEvalCount; + + const blobSource = [ + "globalThis.__nsBlobEvalCount = (globalThis.__nsBlobEvalCount || 0) + 1;", + "export const evalCount = globalThis.__nsBlobEvalCount;", + "export const kind = 'blob-module';", + "export default { evalCount, kind };", + ].join("\n"); + + const url = URL.createObjectURL(new Blob([blobSource], { type: "text/javascript" }), { + ext: ".mjs", + }); + + expect(typeof url).toBe("string"); + expect(url.indexOf("blob:nativescript/")).toBe(0); + + try { + const [first, second] = await Promise.all([import(url), import(url)]); + const third = await import(url); + + expect(first).toBeDefined(); + expect(second).toBe(first); + expect(third).toBe(first); + expect(first.evalCount).toBe(1); + expect(second.evalCount).toBe(1); + expect(third.evalCount).toBe(1); + expect(first.kind).toBe("blob-module"); + expect(globalThis.__nsBlobEvalCount).toBe(1); + } finally { + URL.revokeObjectURL(url); + delete globalThis.__nsBlobEvalCount; + } + }); }); diff --git a/TestRunner/app/tests/esm/hmr/hot-data-ext.js b/TestRunner/app/tests/esm/hmr/hot-data-ext.js new file mode 100644 index 00000000..990b8014 --- /dev/null +++ b/TestRunner/app/tests/esm/hmr/hot-data-ext.js @@ -0,0 +1,79 @@ +// HMR hot.data test module (.js). +// +// INTENTIONAL twin of hot-data-ext.mjs. Two physical files with +// different extensions are required so the HMR canonical-key +// extension-collapse path is actually exercised by tests that import +// BOTH variants (see HttpEsmLoaderTests +// "should share hot.data across .mjs and .js variants"). Each file +// MUST own its own `import.meta.hot` reference — re-exporting from the +// sibling would defeat the test, because `dataMjs === dataJs` would +// then hold trivially via function identity instead of validating the +// runtime's canonical-key normalization. +// +// Keep the body in lock-step with `hot-data-ext.mjs`. + +export function getHot() { + return (typeof import.meta !== "undefined" && import.meta) ? import.meta.hot : undefined; +} + +export function getHotData() { + const hot = getHot(); + return hot ? hot.data : undefined; +} + +export function setHotValue(value) { + const hot = getHot(); + if (!hot || !hot.data) { + throw new Error("import.meta.hot.data is not available"); + } + hot.data.value = value; + return hot.data.value; +} + +export function getHotValue() { + const hot = getHot(); + return hot && hot.data ? hot.data.value : undefined; +} + +export function testHotApi() { + const hot = getHot(); + const result = { + ok: false, + hasHot: !!hot, + hasData: !!(hot && hot.data), + hasAccept: !!(hot && typeof hot.accept === "function"), + hasDispose: !!(hot && typeof hot.dispose === "function"), + hasDecline: !!(hot && typeof hot.decline === "function"), + hasInvalidate: !!(hot && typeof hot.invalidate === "function"), + hasPrune: !!(hot && typeof hot.prune === "function"), + }; + + try { + if (hot && typeof hot.accept === "function") { + hot.accept(function () {}); + } + if (hot && typeof hot.dispose === "function") { + hot.dispose(function () {}); + } + if (hot && typeof hot.decline === "function") { + hot.decline(); + } + if (hot && typeof hot.invalidate === "function") { + hot.invalidate(); + } + result.ok = + result.hasHot && + result.hasData && + result.hasAccept && + result.hasDispose && + result.hasDecline && + result.hasInvalidate && + result.hasPrune; + } catch (e) { + result.error = (e && e.message) ? e.message : String(e); + } + + return result; +} + +console.log("HMR hot.data ext module loaded (.js)"); diff --git a/TestRunner/app/tests/esm/hmr/hot-data-ext.mjs b/TestRunner/app/tests/esm/hmr/hot-data-ext.mjs new file mode 100644 index 00000000..f191e719 --- /dev/null +++ b/TestRunner/app/tests/esm/hmr/hot-data-ext.mjs @@ -0,0 +1,79 @@ +// HMR hot.data test module (.mjs). +// +// INTENTIONAL twin of hot-data-ext.js. Two physical files with +// different extensions are required so the HMR canonical-key +// extension-collapse path is actually exercised by tests that import +// BOTH variants (see HttpEsmLoaderTests +// "should share hot.data across .mjs and .js variants"). Each file +// MUST own its own `import.meta.hot` reference — re-exporting from the +// sibling would defeat the test, because `dataMjs === dataJs` would +// then hold trivially via function identity instead of validating the +// runtime's canonical-key normalization. +// +// Keep the body in lock-step with `hot-data-ext.js`. + +export function getHot() { + return (typeof import.meta !== "undefined" && import.meta) ? import.meta.hot : undefined; +} + +export function getHotData() { + const hot = getHot(); + return hot ? hot.data : undefined; +} + +export function setHotValue(value) { + const hot = getHot(); + if (!hot || !hot.data) { + throw new Error("import.meta.hot.data is not available"); + } + hot.data.value = value; + return hot.data.value; +} + +export function getHotValue() { + const hot = getHot(); + return hot && hot.data ? hot.data.value : undefined; +} + +export function testHotApi() { + const hot = getHot(); + const result = { + ok: false, + hasHot: !!hot, + hasData: !!(hot && hot.data), + hasAccept: !!(hot && typeof hot.accept === "function"), + hasDispose: !!(hot && typeof hot.dispose === "function"), + hasDecline: !!(hot && typeof hot.decline === "function"), + hasInvalidate: !!(hot && typeof hot.invalidate === "function"), + hasPrune: !!(hot && typeof hot.prune === "function"), + }; + + try { + if (hot && typeof hot.accept === "function") { + hot.accept(function () {}); + } + if (hot && typeof hot.dispose === "function") { + hot.dispose(function () {}); + } + if (hot && typeof hot.decline === "function") { + hot.decline(); + } + if (hot && typeof hot.invalidate === "function") { + hot.invalidate(); + } + result.ok = + result.hasHot && + result.hasData && + result.hasAccept && + result.hasDispose && + result.hasDecline && + result.hasInvalidate && + result.hasPrune; + } catch (e) { + result.error = (e && e.message) ? e.message : String(e); + } + + return result; +} + +console.log("HMR hot.data ext module loaded (.mjs)"); diff --git a/TestRunnerTests/TestRunnerTests.swift b/TestRunnerTests/TestRunnerTests.swift index 312ff05e..feae311b 100644 --- a/TestRunnerTests/TestRunnerTests.swift +++ b/TestRunnerTests/TestRunnerTests.swift @@ -9,26 +9,118 @@ class TestRunnerTests: XCTestCase { override func setUp() { continueAfterFailure = false - // Standalone (not via self.expectation(...)) so we can drive it through - // XCTWaiter alongside the crash watchdog without tripping the - // XCTestCase "must waitForExpectations" rule. - runtimeUnitTestsExpectation = XCTestExpectation(description: "Jasmine tests") + runtimeUnitTestsExpectation = self.expectation(description: "Jasmine tests") loop = try! SelectorEventLoop(selector: try! KqueueSelector()) - self.server = DefaultHTTPServer(eventLoop: loop!, port: port) { + self.server = DefaultHTTPServer(eventLoop: loop!, interface: "127.0.0.1", port: port) { ( environ: [String: Any], startResponse: @escaping ((String, [(String, String)]) -> Void), sendBody: @escaping ((Data) -> Void) ) in + let method = (environ["REQUEST_METHOD"] as? String) ?? "" + let path = (environ["PATH_INFO"] as? String) ?? "/" + let query = (environ["QUERY_STRING"] as? String) ?? "" - let method: String? = environ["REQUEST_METHOD"] as! String? - if method != "POST" { - XCTFail("invalid request method") - startResponse("204 No Content", []) - sendBody(Data()) - self.runtimeUnitTestsExpectation.fulfill() - } else { + // Serve tiny ESM modules for runtime HTTP loader tests. + if method == "GET" { + if path == "/esm/query.mjs" || path == "/ns/m/query.mjs" { + func jsStringLiteral(_ s: String) -> String { + return s + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + } + let nowMs = Int(Date().timeIntervalSince1970 * 1000.0) + let body = """ + export const path = \"\(jsStringLiteral(path))\"; + export const query = \"\(jsStringLiteral(query))\"; + export const evaluatedAt = \(nowMs); + export default { path, query, evaluatedAt }; + """ + startResponse("200 OK", [("Content-Type", "application/javascript; charset=utf-8")]) + sendBody(body.data(using: .utf8) ?? Data()) + return + } + + if path == "/esm/timeout.mjs" { + // Intentionally delay the response so the runtime HTTP loader hits its request timeout. + // This avoids ATS issues from testing against external plain-http URLs. + var delayMs = 6500 + if let pair = query + .split(separator: "&") + .first(where: { $0.hasPrefix("delayMs=") }), + let v = Int(pair.split(separator: "=").last ?? "") { + delayMs = v + } + Thread.sleep(forTimeInterval: Double(delayMs) / 1000.0) + + let nowMs = Int(Date().timeIntervalSince1970 * 1000.0) + let body = "export const evaluatedAt = \(nowMs); export default { evaluatedAt };" + startResponse("200 OK", [("Content-Type", "application/javascript; charset=utf-8")]) + sendBody(body.data(using: .utf8) ?? Data()) + return + } + + // HMR hot.data test modules – serve the same helper code for .mjs and .js variants + if path == "/esm/hmr/hot-data-ext.mjs" || path == "/esm/hmr/hot-data-ext.js" { + let body = """ + // HMR hot.data test module (served by XCTest) + export function getHot() { + return (typeof import.meta !== "undefined" && import.meta) ? import.meta.hot : undefined; + } + export function getHotData() { + const hot = getHot(); + return hot ? hot.data : undefined; + } + export function setHotValue(value) { + const hot = getHot(); + if (!hot || !hot.data) { throw new Error("import.meta.hot.data is not available"); } + hot.data.value = value; + return hot.data.value; + } + export function getHotValue() { + const hot = getHot(); + return hot && hot.data ? hot.data.value : undefined; + } + export function testHotApi() { + const hot = getHot(); + const result = { + ok: false, + hasHot: !!hot, + hasData: !!(hot && hot.data), + hasAccept: !!(hot && typeof hot.accept === "function"), + hasDispose: !!(hot && typeof hot.dispose === "function"), + hasDecline: !!(hot && typeof hot.decline === "function"), + hasInvalidate: !!(hot && typeof hot.invalidate === "function"), + pruneIsFalse: !!(hot && hot.prune === false), + }; + try { + if (hot && typeof hot.accept === "function") { hot.accept(function () {}); } + if (hot && typeof hot.dispose === "function") { hot.dispose(function () {}); } + if (hot && typeof hot.decline === "function") { hot.decline(); } + if (hot && typeof hot.invalidate === "function") { hot.invalidate(); } + result.ok = result.hasHot && result.hasData && result.hasAccept && result.hasDispose && result.hasDecline && result.hasInvalidate && result.pruneIsFalse; + } catch (e) { + result.error = String(e); + } + return result; + } + console.log("HMR hot.data ext module loaded (via XCTest server)"); + """ + startResponse("200 OK", [("Content-Type", "application/javascript; charset=utf-8")]) + sendBody(body.data(using: .utf8) ?? Data()) + return + } + + startResponse("404 Not Found", [("Content-Type", "text/plain; charset=utf-8")]) + sendBody(Data("Not Found".utf8)) + return + } + + // Collect Jasmine JUnit report. + if method == "POST" && path == "/junit_report" { var buffer = Data() let input = environ["swsgi.input"] as! SWSGIInput var finished = false @@ -46,7 +138,11 @@ class TestRunnerTests: XCTestCase { self.runtimeUnitTestsExpectation.fulfill() } } + return } + + startResponse("404 Not Found", [("Content-Type", "text/plain; charset=utf-8")]) + sendBody(Data("Not Found".utf8)) } try! server.start() @@ -61,45 +157,11 @@ class TestRunnerTests: XCTestCase { loop.stop() } - func testRuntime() { - let jasmineTestsTimeout: TimeInterval = 300 - + func testRuntime() { let app = XCUIApplication() - app.launchEnvironment["REPORT_BASEURL"] = "http://[::1]:\(port)/junit_report" + app.launchEnvironment["REPORT_BASEURL"] = "http://127.0.0.1:\(port)/junit_report" app.launch() - // Watchdog: if the runtime crashes (e.g. EXC_BAD_ACCESS) it never - // POSTs results, and a plain `wait(for:)` would sit out the full - // timeout. Fulfill the same expectation from the watchdog when the - // app process leaves the running state, and track the crash via a - // flag so we can still distinguish the two outcomes after the wait. - var didCrash = false - let crashWatchdog = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in - if app.state == .notRunning { - didCrash = true - self.runtimeUnitTestsExpectation.fulfill() - } - } - // The XCUITest run loop spins in default mode during wait(for:); add - // the timer to common modes too in case anything switches it. - RunLoop.main.add(crashWatchdog, forMode: .common) - - let result = XCTWaiter().wait( - for: [runtimeUnitTestsExpectation], - timeout: jasmineTestsTimeout - ) - crashWatchdog.invalidate() - - switch result { - case .completed: - if didCrash { - XCTFail("TestRunner exited before reporting Jasmine results (likely crashed). Check ~/Library/Logs/DiagnosticReports/TestRunner-*.ips for the stack.") - } - return - case .timedOut: - XCTFail("Asynchronous wait failed: exceeded \(Int(jasmineTestsTimeout)) seconds with unfulfilled \"Jasmine tests\" expectation") - default: - XCTFail("Unexpected XCTWaiter result: \(result.rawValue)") - } + wait(for: [runtimeUnitTestsExpectation], timeout: 300.0, enforceOrder: true) } }