diff --git a/.github/workflows/npm_release.yml b/.github/workflows/npm_release.yml index 52d8d8d1..9f8ce9c3 100644 --- a/.github/workflows/npm_release.yml +++ b/.github/workflows/npm_release.yml @@ -174,6 +174,46 @@ jobs: # "Existing file at -resultBundlePath". on_retry_command: rm -rf $TEST_FOLDER/test_results_attempt1.xcresult; mv $TEST_FOLDER/test_results.xcresult $TEST_FOLDER/test_results_attempt1.xcresult 2>/dev/null; for f in $TEST_FOLDER/test_results*; do [ "$f" = "$TEST_FOLDER/test_results_attempt1.xcresult" ] || rm -rf "$f"; done; xcrun simctl shutdown all new_command_on_retry: xcodebuild -project v8ios.xcodeproj -scheme TestRunner -resultBundlePath $TEST_FOLDER/test_results -destination platform\=iOS\ Simulator,OS\=latest,name\=iPhone\ 16\ Pro build test + # When the runtime suite fails it is almost always because the in-app + # Jasmine run died before POSTing results (crash or hang). The xcresult is + # black-box and captures nothing from inside the app, so collect the two + # things that actually explain it: the native crash report (.ips) and the + # simulator's unified log (the app's console.log / last spec before a stall). + # The watchdog in TestRunnerTests.swift prints which artifact to look at. + - name: Collect crash reports & simulator log (on failure) + if: ${{ failure() }} + run: | + DIAG="$TEST_FOLDER/diagnostics" + mkdir -p "$DIAG" + # Simulator app crashes land in the host's DiagnosticReports. + cp -R ~/Library/Logs/DiagnosticReports/. "$DIAG/DiagnosticReports/" 2>/dev/null || true + cp -R ~/Library/Logs/CoreSimulator/. "$DIAG/CoreSimulator/" 2>/dev/null || true + # Unified log = the app's console output (so the last spec before a hang + # is visible even when nothing was POSTed). `log collect` needs a booted + # device; don't rely on the `booted` alias (the prior collect failed + # because the sim wasn't booted at that moment). Resolve a concrete UDID + # — prefer one already booted from the test run, else the test device, + # booting it so the persisted log store can be collected. + UDID="$(xcrun simctl list devices booted | grep -oE '[0-9A-Fa-f-]{36}' | head -1)" + if [ -z "$UDID" ]; then + UDID="$(xcrun simctl list devices 'iPhone 16 Pro' | grep -oE '[0-9A-Fa-f-]{36}' | head -1)" + [ -n "$UDID" ] && xcrun simctl boot "$UDID" 2>/dev/null || true + [ -n "$UDID" ] && xcrun simctl bootstatus "$UDID" 2>/dev/null || true + fi + if [ -n "$UDID" ]; then + echo "Collecting unified log from simulator $UDID" + xcrun simctl spawn "$UDID" log collect --output "$DIAG/simulator.logarchive" 2>/dev/null || true + else + echo "No simulator UDID resolved; skipping logarchive collection." + fi + echo "Collected diagnostics:"; ls -laR "$DIAG" 2>/dev/null || true + - name: Upload test diagnostics (on failure) + if: ${{ failure() }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: test-diagnostics + path: ${{ env.TEST_FOLDER }}/diagnostics + if-no-files-found: ignore - name: Validate Test Results run: | xcparse attachments $TEST_FOLDER/test_results.xcresult $TEST_FOLDER/test-out diff --git a/NativeScript/NativeScript.mm b/NativeScript/NativeScript.mm index caf37186..1ed91eb9 100644 --- a/NativeScript/NativeScript.mm +++ b/NativeScript/NativeScript.mm @@ -3,6 +3,7 @@ #include "inspector/JsV8InspectorClient.h" #include "runtime/Console.h" #include "runtime/Helpers.h" +#include "runtime/ModuleInternalCallbacks.h" #include "runtime/Runtime.h" #include "runtime/RuntimeConfig.h" #include "runtime/Tasks.h" @@ -43,6 +44,23 @@ - (void)runMainApplication { CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0, true); tns::Tasks::Drain(); + + // Async-pipeline boot handoff. For UI apps Tasks::Drain() invokes + // UIApplicationMain and never returns — the app's main runloop services + // any in-flight async module loads. When Drain returns (the entry never + // called UIApplicationMain — e.g. a top-level-await entry still loading + // its graph), pump a manual runloop until the pending module work + // settles, Node-like. A load completion may itself register the + // UIApplicationMain task, so drain after each slice; if that drain calls + // UIApplicationMain, it takes over from here and never returns. + if (tns::HasPendingAsyncModuleGraphWork()) { + const CFAbsoluteTime deadline = CFAbsoluteTimeGetCurrent() + 120.0; + while (tns::HasPendingAsyncModuleGraphWork() && CFAbsoluteTimeGetCurrent() < deadline) { + CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.01, true); + tns::Tasks::Drain(); + } + tns::Tasks::Drain(); + } } - (bool)liveSync { diff --git a/NativeScript/runtime/ConcurrentQueue.cpp b/NativeScript/runtime/ConcurrentQueue.cpp index ee55723a..272c162c 100644 --- a/NativeScript/runtime/ConcurrentQueue.cpp +++ b/NativeScript/runtime/ConcurrentQueue.cpp @@ -40,6 +40,21 @@ std::vector> ConcurrentQueue::PopAll() { return messages; } +bool ConcurrentQueue::IsEmpty() { + std::unique_lock mlock(this->mutex_); + return this->messagesQueue_.empty(); +} + +void ConcurrentQueue::Signal() { + // Mirrors Push()'s validity handling instead of SignalAndWakeUp()'s + // assert: a retry racing Terminate() must be a silent no-op. + if (this->runLoopTasksSource_ == nullptr || + !CFRunLoopSourceIsValid(this->runLoopTasksSource_)) { + return; + } + this->SignalAndWakeUp(); +} + void ConcurrentQueue::SignalAndWakeUp() { if (this->runLoopTasksSource_ != nullptr) { tns::Assert(CFRunLoopSourceIsValid(this->runLoopTasksSource_)); diff --git a/NativeScript/runtime/ConcurrentQueue.h b/NativeScript/runtime/ConcurrentQueue.h index 84dff251..e7243f75 100644 --- a/NativeScript/runtime/ConcurrentQueue.h +++ b/NativeScript/runtime/ConcurrentQueue.h @@ -15,6 +15,12 @@ struct ConcurrentQueue { void Initialize(CFRunLoopRef runLoop, void (*performWork)(void*), void* info); void Push(std::shared_ptr message); std::vector> PopAll(); + bool IsEmpty(); + // Re-arm the drain source without enqueueing a new message — used to + // retry delivery of already-queued messages (e.g. a worker whose entry + // script hasn't installed `onmessage` yet). Safe from any thread; a + // no-op once terminated. + void Signal(); void Terminate(); private: std::queue> messagesQueue_; diff --git a/NativeScript/runtime/DataWrapper.h b/NativeScript/runtime/DataWrapper.h index 25cfd37b..8eb20fbc 100644 --- a/NativeScript/runtime/DataWrapper.h +++ b/NativeScript/runtime/DataWrapper.h @@ -544,6 +544,9 @@ class WorkerWrapper : public BaseDataWrapper { const std::string& stackTrace, int lineNumber, bool async = true); void PostMessage(std::shared_ptr message); + // Re-arm the message drain without enqueueing — used by the deferred-drain + // retry when the worker's entry script hasn't installed `onmessage` yet. + void SignalMessageDrain(); void Close(); void Terminate(); @@ -566,6 +569,9 @@ class WorkerWrapper : public BaseDataWrapper { std::atomic isTerminating_; std::atomic isDisposed_; std::atomic isWeak_; + // True while a deferred drain retry is in flight (see DrainPendingTasks) — + // prevents stacking one retry per drain attempt. + std::atomic drainRetryPending_; std::function thiz, std::shared_ptr)> onMessage_; diff --git a/NativeScript/runtime/DevFlags.h b/NativeScript/runtime/DevFlags.h index 01533ae1..9383e3b4 100644 --- a/NativeScript/runtime/DevFlags.h +++ b/NativeScript/runtime/DevFlags.h @@ -12,6 +12,14 @@ namespace tns { // Controlled by package.json setting: "logScriptLoading": true|false bool IsScriptLoadingLogEnabled(); +// HTTP module loader flags +// +// 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..c8e60759 100644 --- a/NativeScript/runtime/DevFlags.mm +++ b/NativeScript/runtime/DevFlags.mm @@ -1,10 +1,11 @@ #import +#include +#include #include "DevFlags.h" +#include "Helpers.h" #include "Runtime.h" #include "RuntimeConfig.h" -#include -#include namespace tns { @@ -13,16 +14,56 @@ bool IsScriptLoadingLogEnabled() { return value ? [value boolValue] : false; } +// HTTP module loader flags + +// 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; static bool s_allowRemoteModules = false; static std::vector s_remoteModuleAllowlist; -// Helper to check if a URL starts with a given prefix -static bool UrlStartsWith(const std::string& url, const std::string& prefix) { - if (prefix.size() > url.size()) return false; - return url.compare(0, prefix.size(), prefix) == 0; +// Returns true when `url` is authorized by allowlist `entry`. +// +// This is intentionally stricter than a raw string-prefix test: after the +// matched entry text, the next character in `url` must be a URL-component +// boundary ('/', '?', or '#'), the URL must end exactly at the entry, or the +// entry must itself end in '/'. That refuses lookalike-host and lookalike-port +// bypasses — an entry of "https://cdn.example.com" must NOT authorize +// "https://cdn.example.com.attacker.com/x.js" or +// "https://cdn.example.com:9999/x.js". To allow a specific port, include it in +// the allowlist entry (deny-by-default for anything not explicitly listed). +static bool RemoteUrlMatchesAllowlistEntry(const std::string& url, const std::string& entry) { + if (entry.empty()) return false; + if (url.size() < entry.size()) return false; + if (url.compare(0, entry.size(), entry) != 0) return false; + if (url.size() == entry.size()) return true; // exact match + if (entry.back() == '/') return true; // entry ended at a boundary + const char next = url[entry.size()]; + return next == '/' || next == '?' || next == '#'; } void InitializeSecurityConfig() { @@ -83,14 +124,14 @@ bool IsRemoteUrlAllowed(const std::string& url) { if (s_remoteModuleAllowlist.empty()) { return true; } - - // Check if URL matches any allowlist prefix - for (const std::string& prefix : s_remoteModuleAllowlist) { - if (UrlStartsWith(url, prefix)) { + + // Check if URL matches any allowlist entry on a URL-component boundary. + for (const std::string& entry : s_remoteModuleAllowlist) { + if (RemoteUrlMatchesAllowlistEntry(url, entry)) { return true; } } - + return false; } diff --git a/NativeScript/runtime/HMRSupport.h b/NativeScript/runtime/HMRSupport.h index cf8af2a1..c526829a 100644 --- a/NativeScript/runtime/HMRSupport.h +++ b/NativeScript/runtime/HMRSupport.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -7,55 +8,127 @@ // requiring V8 headers at include sites. namespace v8 { class Isolate; -template class Local; +template +class Local; class Object; class Function; class Context; -} +class Value; +} // namespace v8 namespace tns { -// HMRSupport: Isolated helpers for minimal HMR (import.meta.hot) support. +// HMRSupport: the native half of the NativeScript dev-loader contract. // -// This module contains: -// - Per-module hot data store -// - Registration for accept/disable callbacks -// - Initializer to attach import.meta.hot to a module's import.meta +// The runtime deliberately exposes *mechanism* only: +// - the synchronous HTTP text fetch backing the HTTP ESM loader's +// fallback path (V8's ResolveModuleCallback is synchronous — still +// true as of 14.9.207.39 — so the fallback must be native), +// - the async NSURLSession fetch behind the phase-1 module-graph walk +// (StartAsyncHttpModuleGraphLoad), which is how module bodies +// normally arrive, +// - eviction plumbing (an eviction-driven fetch nonce that defeats +// CFNetwork's HTTP cache), +// - the dev-boot-complete signal that disarms cold-boot-only +// behaviors (runloop pump, connection-recovery wait). // -// Note: Triggering/dispatch is handled by the HMR system elsewhere. - -// Retrieve or create the per-module hot data object. -v8::Local GetOrCreateHotData(v8::Isolate* isolate, const std::string& key); - -// Register accept and dispose callbacks for a module key. -void RegisterHotAccept(v8::Isolate* isolate, const std::string& key, v8::Local cb); -void RegisterHotDispose(v8::Isolate* isolate, const std::string& key, v8::Local cb); - -// 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); - -// 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. -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. +// 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, drops known cache busters (t/v/import) +// and sorts remaining query params for stability. +// - For non-dev/public URLs, preserves the full query string as part of the +// cache key. +// Module identity IS the (canonical) URL — the dev server serves every +// module under exactly one URL and never varies it for freshness. std::string CanonicalizeHttpUrlKey(const std::string& url); -// 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 -bool HttpFetchText(const std::string& url, std::string& out, std::string& contentType, int& status); +// +// Synchronous fetch with one retry — this is the fallback path for +// anything the async module-graph walk missed. +bool HttpFetchText(const std::string& url, std::string& out, + std::string& contentType, int& status); + +// Asynchronous single-URL module body fetch — the I/O primitive behind the +// phase-1 module-graph walk (see StartAsyncHttpModuleGraphLoad in +// ModuleInternalCallbacks.h). Same semantics as HttpFetchText, minus the +// JS-thread block: +// - security gate (IsRemoteUrlAllowed) checked up front, +// - an NSURLSession GET on a background queue with the same request +// shape as the sync path (cache-bust nonce, zero-cache headers, +// no cookies) and one retry on transport error, +// - empty 2xx bodies normalize to the canonical empty module. +// `completion(ok, status, body)` is invoked exactly once, on an arbitrary +// thread — callers must hop to their JS thread before touching V8. +void FetchModuleBodyAsync( + const std::string& url, + std::function completion); + +// 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 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)()); + +// Mark a URL set (canonicalized internally) so that the NEXT network +// fetch of each URL carries a unique `__ns_dev_nonce` query parameter, +// guaranteeing CFNetwork cannot satisfy the request from any HTTP cache +// layer (observed on iOS 18+/26+ Simulator even with `no-store` headers +// and a reload-ignoring cache policy). Called by `InvalidateModules` for +// the eviction set; marks are consumed when a fresh body arrives. +// The nonce is transport-only and never affects module identity. +void MarkUrlsForCacheBust(const std::vector& urls); + +// Flip the dev-boot-complete signal: sets the JS-visible +// `__NS_HMR_BOOT_COMPLETE__` global and the native atomic that gates the +// cold-boot-only behaviors (JS-thread runloop pump between synchronous +// fetches). Exposed to JS as ns:runtime +// `setDevBootComplete(value?: boolean)`. +void SetDevBootComplete(v8::Isolate* isolate, v8::Local context, + bool value); + +// Clear process-wide dev-loader state (cache-bust marks, boot-complete +// flag, canonicalization vocabulary). MUST be called inside +// Runtime::~Runtime() before isolate disposal — and only for the MAIN +// isolate (worker teardown must not wipe shared state the main isolate +// still uses). +void CleanupHMRGlobals(); + +// ───────────────────────────────────────────────────────────── +// The `ns:runtime` builtin binding +// +// Populates the native half of the `ns:runtime` builtin module — the one +// namespace carrying every JS-callable dev primitive that any tooling can +// depend on. Called from NsBuiltinModules::BuildBinding the first time a +// realm resolves `ns:runtime` (via require, static import, or import()); +// ns-runtime.js shapes and freezes the exports. +// +// `ns:runtime` members: +// - configureRuntime(config) (import map + volatile patterns + +// canonicalization vocabulary) +// - invalidateModules(urls) (registry + cache eviction) +// - getLoadedModuleUrls() (registry introspection) +// - setDevBootComplete(value?) (boot-complete signal) +// - terminateAllWorkers() (main realm only; see Worker.h) +// - canonicalizeHttpUrlKey(url) (debug builds only; test diagnostic) +// +// Returns false (with an exception pending or a failed Set) when the +// binding could not be populated. +bool BuildNsRuntimeBinding(v8::Local context, + v8::Local binding); -} // namespace tns +} // namespace tns diff --git a/NativeScript/runtime/HMRSupport.mm b/NativeScript/runtime/HMRSupport.mm index 66cd3262..95f244b1 100644 --- a/NativeScript/runtime/HMRSupport.mm +++ b/NativeScript/runtime/HMRSupport.mm @@ -5,10 +5,17 @@ #include #include "DevFlags.h" -#include -#include +#include +#include #include +#include +#include "Caches.h" #include "Helpers.h" +#include "ModuleInternalCallbacks.h" +#include "Runtime.h" +#include "RuntimeConfig.h" +#include "Worker.h" +#include "robin_hood.h" // Use centralized dev flags helper for logging @@ -19,154 +26,97 @@ static inline bool StartsWith(const std::string& s, const char* prefix) { return s.size() >= n && s.compare(0, n, prefix) == 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; - -v8::Local GetOrCreateHotData(v8::Isolate* isolate, const std::string& key) { - auto it = g_hotData.find(key); - if (it != g_hotData.end()) { - if (!it->second.IsEmpty()) { - return it->second.Get(isolate); - } - } - v8::Local obj = v8::Object::New(isolate); - g_hotData[key].Reset(isolate, obj); - return obj; -} - -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)); +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); } -void RegisterHotDispose(v8::Isolate* isolate, const std::string& key, v8::Local cb) { - if (cb.IsEmpty()) return; - g_hotDispose[key].emplace_back(v8::Global(isolate, cb)); -} +// ───────────────────────────────────────────────────────────── +// Dev-boot completion flag +// +// 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. The JS dev +// client flips this via ns:runtime +// `setDevBootComplete(bool)` once the real app root view +// commits; boot orchestration itself is entirely userland. +static std::atomic g_devSessionBootComplete{false}; -std::vector> GetHotAcceptCallbacks(v8::Isolate* isolate, const std::string& key) { - std::vector> out; - auto it = g_hotAccept.find(key); - if (it != g_hotAccept.end()) { - for (auto& gfn : it->second) { - if (!gfn.IsEmpty()) out.push_back(gfn.Get(isolate)); - } - } - return out; +static inline bool IsDevSessionBootComplete() { + return g_devSessionBootComplete.load(std::memory_order_relaxed); } -std::vector> GetHotDisposeCallbacks(v8::Isolate* isolate, const std::string& key) { - std::vector> out; - auto it = g_hotDispose.find(key); - if (it != g_hotDispose.end()) { - for (auto& gfn : it->second) { - if (!gfn.IsEmpty()) out.push_back(gfn.Get(isolate)); - } +void SetDevBootComplete(v8::Isolate* isolate, v8::Local context, bool value) { + SetBooleanGlobal(isolate, context, "__NS_HMR_BOOT_COMPLETE__", value); + g_devSessionBootComplete.store(value, std::memory_order_relaxed); + if (IsScriptLoadingLogEnabled()) { + Log(@"[dev-boot] __NS_HMR_BOOT_COMPLETE__=%s", value ? "true" : "false"); } - return out; } -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); - - // 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) { - 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)); - }; - - // decline() — mark declined (no-op for now) - auto declineCb = [](const FunctionCallbackInfo& info) { - info.GetReturnValue().Set(v8::Undefined(info.GetIsolate())); - }; +// ───────────────────────────────────────────────────────────── +// HTTP loader helpers - // invalidate() — no-op for now - auto invalidateCb = [](const FunctionCallbackInfo& info) { - info.GetReturnValue().Set(v8::Undefined(info.GetIsolate())); - }; +// Canonicalization vocabulary (client-supplied policy). +// +// The canonical-key *mechanism* (fragment strip, cache-buster param drop, +// param sort) must be native because it keys the module registry inside +// V8's synchronous resolve walk. The *vocabulary* — which query params are +// pure cache busters, which path prefixes identify dev endpoints whose +// queries may be normalized, and which paths must keep their query verbatim +// because the query IS the identity — is server/framework policy, supplied +// by the dev client via +// ns:runtime `configureRuntime({ canonicalization: {...} })`. +// +// Write-before-read contract: the client configures this once, before the +// first import wave (session-bootstrap order), so plain statics are +// safe here — the same convention as `g_volatilePatterns` / `g_importMap`. +// URLs touched before configuration (the local trampoline's clean +// `/ns/core/*` imports) carry no query, so they canonicalize identically +// under any vocabulary. +// +// When unconfigured, a built-in vocabulary matching current +// `@nativescript/vite` conventions applies. +struct CanonicalizationConfig { + std::vector stripParams; // query param names to drop + std::vector devPathPrefixes; // path StartsWith → normalize query + std::vector preserveQueryPrefixes; // path contains → preserve query verbatim +}; +static CanonicalizationConfig g_canonConfig; +static bool g_canonConfigured = false; - 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(); - - // Attach to import.meta - importMeta->CreateDataProperty( - context, tns::ToV8String(isolate, "hot"), - hot).Check(); +static void SetCanonicalizationConfig(CanonicalizationConfig config) { + g_canonConfig = std::move(config); + g_canonConfigured = true; + if (IsScriptLoadingLogEnabled()) { + Log(@"[ns:runtime configureRuntime] canonicalization set (strip=%lu devPrefixes=%lu " + @"preserve=%lu)", + (unsigned long)g_canonConfig.stripParams.size(), + (unsigned long)g_canonConfig.devPathPrefixes.size(), + (unsigned long)g_canonConfig.preserveQueryPrefixes.size()); + } } -// ───────────────────────────────────────────────────────────── -// Dev HTTP loader helpers +static void ResetCanonicalizationConfig() { + g_canonConfig = CanonicalizationConfig{}; + g_canonConfigured = false; +} std::string CanonicalizeHttpUrlKey(const std::string& url) { - if (!(StartsWith(url, "http://") || StartsWith(url, "https://"))) { - return url; + // Some loaders wrap HTTP module URLs as file://http(s)://... + std::string normalizedUrl = url; + if (StartsWith(normalizedUrl, "file://http://") || StartsWith(normalizedUrl, "file://https://")) { + normalizedUrl = normalizedUrl.substr(strlen("file://")); + } + if (!(StartsWith(normalizedUrl, "http://") || StartsWith(normalizedUrl, "https://"))) { + return normalizedUrl; } // Drop fragment entirely - size_t hashPos = url.find('#'); - std::string noHash = (hashPos == std::string::npos) ? url : url.substr(0, hashPos); + size_t hashPos = normalizedUrl.find('#'); + std::string noHash = + (hashPos == std::string::npos) ? normalizedUrl : normalizedUrl.substr(0, hashPos); // Locate path start and query start size_t schemePos = noHash.find("://"); @@ -184,47 +134,80 @@ 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: - // - /ns/rt/ -> /ns/rt - // - /ns/core/ -> /ns/core - // Preserve query params (e.g. /ns/core?p=...) as part of module identity. + // 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 query normalization (sorting/dropping) applies only to dev + // endpoints, per the client-supplied vocabulary above. + // + // The dev server serves every module under ONE canonical URL — module + // identity IS the URL string. Freshness after an HMR edit is handled by + // ns:runtime `invalidateModules` (registry evict) plus the + // eviction-driven fetch nonce in `PerformHttpFetchOnceSync`, never by URL + // variation. There is deliberately no path-tag vocabulary to collapse here. + // + // Why `preserveQueryFor` exists (and is checked BEFORE the dev-endpoint + // prefix test, so it covers nested paths like + // `/ns/m//@ng/component`): some endpoints' query IS the + // identity. Angular's `/@ng/component?c=&t=` is the canonical + // example — each `t` identifies a specific recompile of the component's + // metadata, and stripping it would collapse every HMR fetch to the + // boot-time cache key, so `ɵɵreplaceMetadata` would forever replay stale + // template instructions ("server logs hmr update, screen never changes"). { std::string pathOnly = originAndPath.substr(pathStart); - auto normalizeBridge = [&](const char* needle) { - size_t nlen = strlen(needle); - if (pathOnly.compare(0, nlen, needle) != 0) return; - if (pathOnly.size() == nlen) return; // already canonical - if (pathOnly.size() <= nlen + 1 || pathOnly[nlen] != '/') return; - - // Only normalize exact version segment: /ns/*/ (no further segments) - size_t i = nlen + 1; - size_t j = i; - while (j < pathOnly.size() && std::isdigit(static_cast(pathOnly[j]))) { - j++; + if (g_canonConfigured) { + for (const auto& p : g_canonConfig.preserveQueryPrefixes) { + if (!p.empty() && pathOnly.find(p) != std::string::npos) { + return noHash; // query preserved verbatim (fragment already removed) + } } - if (j == i) return; // no digits - if (j != pathOnly.size()) return; // has extra path - - originAndPath = originAndPath.substr(0, pathStart) + std::string(needle); - pathOnly = originAndPath.substr(pathStart); - }; - - normalizeBridge("/ns/rt"); - normalizeBridge("/ns/core"); + bool isDevEndpoint = false; + for (const auto& p : g_canonConfig.devPathPrefixes) { + if (!p.empty() && StartsWith(pathOnly, p.c_str())) { + isDevEndpoint = true; + break; + } + } + if (!isDevEndpoint) { + return noHash; + } + } else { + // Unconfigured: built-in vocabulary matching `@nativescript/vite` + // conventions. + if (pathOnly.find("/@ng/component") != std::string::npos) { + return noHash; + } + const bool isDevEndpoint = StartsWith(pathOnly, "/ns/") || + StartsWith(pathOnly, "/node_modules/.vite/") || + StartsWith(pathOnly, "/@id/") || StartsWith(pathOnly, "/@fs/"); + if (!isDevEndpoint) { + return noHash; + } + } } if (query.empty()) return originAndPath; - // Keep all params except Vite's import marker; sort for stability. + // Keep all params except the configured cache busters; sort for stability. std::vector kept; size_t start = 0; while (start <= query.size()) { size_t amp = query.find('&', start); - std::string pair = (amp == std::string::npos) ? query.substr(start) : query.substr(start, amp - start); + std::string pair = + (amp == std::string::npos) ? query.substr(start) : query.substr(start, amp - start); if (!pair.empty()) { size_t eq = pair.find('='); std::string name = (eq == std::string::npos) ? pair : pair.substr(0, eq); - if (!(name == "import")) kept.push_back(pair); + bool drop; + if (g_canonConfigured) { + drop = std::find(g_canonConfig.stripParams.begin(), g_canonConfig.stripParams.end(), + name) != g_canonConfig.stripParams.end(); + } else { + // Built-in fallback: Vite's import marker and t/v cache stamps. + drop = (name == "import" || name == "t" || name == "v"); + } + if (!drop) kept.push_back(pair); } if (amp == std::string::npos) break; start = amp + 1; @@ -239,87 +222,821 @@ void InitializeImportMetaHot(v8::Isolate* isolate, return rebuilt; } -bool HttpFetchText(const std::string& url, std::string& out, std::string& contentType, int& status) { +// ───────────────────────────────────────────────────────────── +// Eviction-driven fetch cache-bust +// +// When the HMR client invalidates a module, the NEXT network fetch of +// that module must not be satisfiable by any OS-level HTTP cache +// (CFNetwork's fsCachedData has been observed serving a previous +// save's body on iOS 18+/26+ Simulator even with `no-store` headers +// and a reload-ignoring cache policy). `InvalidateModules` marks the +// canonical keys of the eviction set here; `PerformHttpFetchOnceSync` +// then appends a unique `__ns_dev_nonce` query parameter to the +// wire-level request for any marked URL, guaranteeing CFNetwork sees +// a URL it has never cached. The nonce is transport-only — it never +// enters the module registry key (identity stays the canonical URL), +// and the server and the registry never see a varied URL. +static std::mutex g_bustNextFetchMutex; +static robin_hood::unordered_set g_bustNextFetchKeys; + +void MarkUrlsForCacheBust(const std::vector& urls) { + if (urls.empty()) return; + std::lock_guard lock(g_bustNextFetchMutex); + for (const auto& url : urls) { + if (url.empty()) continue; + if (!(StartsWith(url, "http://") || StartsWith(url, "https://"))) continue; + g_bustNextFetchKeys.insert(CanonicalizeHttpUrlKey(url)); + } +} + +// Peek (do not consume) — the fetch may be retried on transient failure +// and the retry must still carry a nonce. Cleared on fetch success. +static bool IsUrlMarkedForCacheBust(const std::string& url) { + std::lock_guard lock(g_bustNextFetchMutex); + if (g_bustNextFetchKeys.empty()) return false; + return g_bustNextFetchKeys.find(CanonicalizeHttpUrlKey(url)) != g_bustNextFetchKeys.end(); +} + +static void ClearCacheBustForUrl(const std::string& url) { + std::lock_guard lock(g_bustNextFetchMutex); + if (g_bustNextFetchKeys.empty()) return; + g_bustNextFetchKeys.erase(CanonicalizeHttpUrlKey(url)); +} + +static void ClearAllCacheBustMarks() { + std::lock_guard lock(g_bustNextFetchMutex); + g_bustNextFetchKeys.clear(); +} + +// ============================================================================ +// HTTP module fetching +// ============================================================================ +// +// Two fetch primitives back the HTTP ESM loader: +// - `HttpFetchText` — the synchronous fetch V8's ResolveModuleCallback +// falls back to for anything the async module-graph walk missed +// (the callback is synchronous — still true as of 14.9.207.39 — so +// this fallback must be native and blocking). +// - `FetchModuleBodyAsync` — the NSURLSession-backed primitive behind +// the phase-1 async graph walk (StartAsyncHttpModuleGraphLoad), +// which fetches the transitive closure concurrently off the JS +// thread before instantiation begins. +// +// These two are the loader's complete fetch surface: the async graph walk +// owns all body fetching. Concurrent per-module fetches overlap with +// on-device compile, which measured fastest on real apps +// (HMR_API_NECESSITY_REVIEW.md §8.3). + +// 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 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(); + +// 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. if (!IsRemoteUrlAllowed(url)) { - status = 403; // Forbidden + status = 403; // Forbidden if (IsScriptLoadingLogEnabled()) { Log(@"[http-esm][security][blocked] %s", url.c_str()); } return false; } - + + // Hoist the URL-log flag once per call so the success branches below pay + // one TLS read instead of two. + const bool urlLogEnabled = IsHttpFetchUrlLogEnabled(); + + // Synchronous fetch with one retry on failure. + // 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); + } + // NOTE: no long dev-server-startup retry loop here on purpose. The CLI's + // `compileWithWatch` gates app deploy/restart on its `vite serve` + // readiness probe (bundler-compiler-service), so a connection-refused at + // boot is a real failure, not a startup race — surface it immediately. + if (!ok || status < 200 || status >= 300) { + return false; + } + // An empty 2xx body is a VALID module response: type-only TypeScript + // modules legitimately transform to zero runtime code (Vite's /@fs + // endpoint serves them as empty 200s). Substitute the canonical empty ESM + // module — treating this as a fetch failure kills the entire dev-session + // graph with a misleading "HTTP import failed (status=200)". + if (out.empty()) { + out = "export {};\n"; + if (IsScriptLoadingLogEnabled()) { + Log(@"[http-loader] empty 2xx body for %s — serving canonical empty module", url.c_str()); + } + } + 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); + } + + // 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`. 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. +// Shared request builder for the sync (NSURLConnection) and async +// (NSURLSession) module fetch paths so both carry identical cache-defeat +// semantics. Returns an autoreleased NSMutableURLRequest (nil for +// unparseable URLs) and reports via `outBustRequested` whether the URL was +// marked for an eviction-driven cache-bust nonce (the caller clears the +// mark once a fresh body actually arrives). +static NSMutableURLRequest* BuildModuleFetchRequest(const std::string& url, + bool* outBustRequested) { + // 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 just-updated URL. + static dispatch_once_t s_cacheDisableOnce; + dispatch_once(&s_cacheDisableOnce, ^{ + NSURLCache* nullCache = [[NSURLCache alloc] initWithMemoryCapacity:0 + diskCapacity:0 + directoryURL:nil]; + [NSURLCache setSharedURLCache:nullCache]; + }); + + // Eviction-driven cache-bust: if this URL's canonical key was marked + // by `InvalidateModules` (via `MarkUrlsForCacheBust`), append a + // unique nonce query parameter so CFNetwork sees a different URL + // and cannot satisfy the request from any cache layer. The dev + // server ignores unknown query params on module routes, so the + // response body is unchanged. First-touch fetches don't need + // busting — nothing has cached them yet — so unmarked URLs go out + // verbatim (some Vite virtual routes require exact-match URLs and + // 404 on unknown query params). + std::string fetchUrl = url; + const bool bustRequested = IsUrlMarkedForCacheBust(url); + if (outBustRequested) *outBustRequested = bustRequested; + if (bustRequested) { + static std::atomic s_fetchSeq{0}; + const uint64_t seq = s_fetchSeq.fetch_add(1, std::memory_order_relaxed); + const uint64_t nowMs = (uint64_t)(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) { + return nil; + } + + 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 from fsCachedData. + // Combined with the zero-capacity sharedURLCache and the eviction- + // driven 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]; + // `setHTTPShouldUsePipelining:` is deprecated on visionOS 2.4+ (classic + // loader only). Passing NO matches the default — pipelining is already + // off — so this is intent-preserving on every platform; suppress the + // deprecation so the -Werror visionOS build keeps compiling. +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + [request setHTTPShouldUsePipelining:NO]; +#pragma clang diagnostic pop + [[NSURLCache sharedURLCache] removeCachedResponseForRequest:request]; + + return request; +} + +static bool PerformHttpFetchOnceSync(const std::string& url, std::string& out, + std::string& contentType, int& status) { @autoreleasepool { - NSURL* u = [NSURL URLWithString:[NSString stringWithUTF8String:url.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(); - }; + bool bustRequested = false; + NSMutableURLRequest* request = BuildModuleFetchRequest(url, &bustRequested); + if (!request) { + status = 0; + return false; + } + + NSError* err = nil; + NSInteger httpStatusLocal = 0; + std::string contentTypeLocal; + std::string bodyLocal; + + const auto fetchStartUs = (uint64_t)(CFAbsoluteTimeGetCurrent() * 1000.0 * 1000.0); + + 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)); + } - 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 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) { + // An empty body on a 2xx with no transport error is a legitimate + // response (type-only TS modules transform to zero runtime code — + // Vite serves them as empty 200s). Only transport errors and empty + // non-2xx responses are fetch failures; HttpFetchText normalizes the + // empty-success body to the canonical empty module. + const bool emptyNon2xx = bodyLocal.empty() && (httpStatusLocal < 200 || httpStatusLocal >= 300); + if (err != nil || emptyNon2xx) { + 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); + // A fresh body arrived from origin — the bust request (if any) has + // been satisfied. Clear the mark so steady-state re-fetches of the + // same URL don't keep paying the nonce (and stay exact-match for + // routes that require it). + if (bustRequested) { + ClearCacheBustForUrl(url); } return true; } } -} // namespace tns +// ───────────────────────────────────────────────────────────── +// Async module fetch (NSURLSession) +// +// The async pipeline's fetches never block the JS thread, so the +// NSURLConnection workaround documented above PerformHttpFetchOnceSync does +// not apply here: that deadlock is specific to *synchronously waiting* on an +// NSURLSession task from the iOS main thread. Fire-and-forget tasks with a +// background delegate queue are the intended NSURLSession usage. +// +// The session is ephemeral with a nil URLCache — the layered cache defeats +// in BuildModuleFetchRequest assume CFNetwork cannot satisfy any module +// request from a cache, and an ephemeral cacheless session is the strongest +// form of that guarantee. +static NSURLSession* ModuleFetchSession() { + static NSURLSession* s_session = nil; + static dispatch_once_t s_once; + dispatch_once(&s_once, ^{ + NSURLSessionConfiguration* config = [NSURLSessionConfiguration ephemeralSessionConfiguration]; + config.URLCache = nil; + config.requestCachePolicy = NSURLRequestReloadIgnoringLocalAndRemoteCacheData; + config.HTTPShouldSetCookies = NO; + config.timeoutIntervalForRequest = 10.0; + config.HTTPMaximumConnectionsPerHost = 16; + // ARC is disabled in this file: sessionWithConfiguration: returns an + // autoreleased object; retain it for the process-lifetime singleton. + s_session = [[NSURLSession sessionWithConfiguration:config] retain]; + }); + return s_session; +} + +// One network attempt for FetchModuleBodyAsync. Takes ownership of +// `completionHeap` (heap-allocated so the ObjC block can carry the +// std::function across threads without ARC) and guarantees exactly one +// invocation + delete. Retries once on transport error, mirroring the +// single-retry policy of the synchronous path. +static void PerformModuleFetchAsyncAttempt( + const std::string& url, int attempt, + std::function* completionHeap) { + @autoreleasepool { + bool bustRequested = false; + NSMutableURLRequest* request = BuildModuleFetchRequest(url, &bustRequested); + if (!request) { + (*completionHeap)(false, 0, std::string()); + delete completionHeap; + return; + } + + const std::string urlCopy = url; + const bool bust = bustRequested; + const uint64_t startUs = (uint64_t)(CFAbsoluteTimeGetCurrent() * 1000.0 * 1000.0); + NSURLSessionDataTask* task = [ModuleFetchSession() + dataTaskWithRequest:request + completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { + int status = 0; + if ([response isKindOfClass:[NSHTTPURLResponse class]]) { + status = (int)[(NSHTTPURLResponse*)response statusCode]; + } + std::string body; + if (data && [data length] > 0) { + body.assign(static_cast([data bytes]), + static_cast([data length])); + } + + // Transport error → one retry (parity with HttpFetchText's + // usleep(120ms)+retry, without blocking any thread). + if (error != nil && attempt == 0) { + if (IsScriptLoadingLogEnabled()) { + Log(@"[http-loader][fetch-async] retrying %s after transport error: %@", + urlCopy.c_str(), error.localizedDescription ?: @""); + } + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(120 * NSEC_PER_MSEC)), + dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + PerformModuleFetchAsyncAttempt(urlCopy, 1, completionHeap); + }); + return; + } + + const bool ok = (error == nil) && status >= 200 && status < 300; + if (ok && body.empty()) { + // Empty 2xx bodies are valid module responses (type-only TS + // modules) — same normalization as the sync path. + body = "export {};\n"; + } + if (ok && bust) { + ClearCacheBustForUrl(urlCopy); + } + if (!ok && IsScriptLoadingLogEnabled()) { + NSString* desc = + error ? (error.localizedDescription ?: @"") : @""; + Log(@"[http-loader][fetch-async][error] url=%s status=%d attempt=%d desc=%@", + urlCopy.c_str(), status, attempt, desc); + } + if (ok && IsHttpFetchUrlLogEnabled()) { + const uint64_t endUs = (uint64_t)(CFAbsoluteTimeGetCurrent() * 1000.0 * 1000.0); + const uint64_t ms = endUs > startUs ? (endUs - startUs) / 1000ull : 0ull; + Log(@"[http-loader][fetch][async] %s bytes=%lu ms=%llu", urlCopy.c_str(), + (unsigned long)body.size(), (unsigned long long)ms); + } + (*completionHeap)(ok, status, std::move(body)); + delete completionHeap; + }]; + [task resume]; + } +} + +void FetchModuleBodyAsync(const std::string& url, + std::function completion) { + // Security gate: single point of enforcement, same as HttpFetchText. + if (!IsRemoteUrlAllowed(url)) { + if (IsScriptLoadingLogEnabled()) { + Log(@"[http-esm][security][blocked] %s", url.c_str()); + } + completion(false, 403, std::string()); + return; + } + + auto* completionHeap = new std::function(std::move(completion)); + PerformModuleFetchAsyncAttempt(url, 0, completionHeap); +} + +// 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 +// background threads, so they never pump someone else's runloop. +// - `IsDevSessionBootComplete()` short-circuits once the dev client +// has committed its first stable view (it calls +// ns:runtime `setDevBootComplete(true)`) — 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 CleanupHMRGlobals() { + ClearAllCacheBustMarks(); + // Reset the boot-complete flag so a re-launched runtime in the same + // process starts in "cold boot" mode again (runloop pump armed). + g_devSessionBootComplete.store(false, std::memory_order_relaxed); + // Drop the client-supplied canonicalization vocabulary so a re-launched + // runtime starts from the built-in fallback until its own client + // configures it. + ResetCanonicalizationConfig(); +} + +// ───────────────────────────────────────────────────────────── +// The `ns:runtime` dev surface +// +// The runtime's dev surface is deliberately small: it exposes +// *mechanism* only (resolution config, registry eviction, registry +// introspection, boot-complete signal). All HMR *policy* — boot +// orchestration, `import.meta.hot`, full reload, CSS apply, WebSocket +// protocol — lives in the JS dev client (`@nativescript/vite`). +// The surface is reachable exclusively through the `ns:runtime` builtin +// module (require / static import / import()); there is no global. + +namespace { + +// Sets the function name on the v8 Function for nicer stack traces and +// attaches it as a member of the `ns:runtime` binding object. +void InstallDevFunction(v8::Isolate* isolate, v8::Local context, + v8::Local target, const char* name, + v8::FunctionCallback callback) { + v8::Local fnTpl = v8::FunctionTemplate::New(isolate, callback); + v8::Local fn = fnTpl->GetFunction(context).ToLocalChecked(); + fn->SetName(tns::ToV8String(isolate, name)); + target->CreateDataProperty(context, tns::ToV8String(isolate, name), fn).Check(); +} + +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(@"[ns:runtime configureRuntime] 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(@"[ns:runtime configureRuntime] import map set (%zu bytes)", jsonStr.size()); + } + } + } + + // Reads `obj[key]` as an array of strings into `out`; non-string elements + // are skipped. Returns true when the property exists and is an array. + auto readStringArray = [&](v8::Local obj, const char* key, + std::vector& out) -> bool { + v8::Local val; + if (!obj->Get(ctx, tns::ToV8String(isolate, key)).ToLocal(&val) || !val->IsArray()) { + return false; + } + v8::Local arr = val.As(); + 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) out.push_back(*utf8); + } + } + return true; + }; + + // Process volatilePatterns: array of strings + { + std::vector patterns; + if (readStringArray(config, "volatilePatterns", patterns) && !patterns.empty()) { + SetVolatilePatterns(patterns); + if (logScriptLoading) { + Log(@"[ns:runtime configureRuntime] %zu volatile patterns set", patterns.size()); + } + } + } + + // Process canonicalization: { stripParams, forPathPrefixes, preserveQueryFor } + // — the URL vocabulary CanonicalizeHttpUrlKey applies (see its doc block). + // Presence of the object marks the vocabulary as configured, replacing the + // built-in fallback entirely (empty arrays are honored as explicit policy). + { + v8::Local canonVal; + if (config->Get(ctx, tns::ToV8String(isolate, "canonicalization")).ToLocal(&canonVal) && + canonVal->IsObject()) { + v8::Local canonObj = canonVal.As(); + CanonicalizationConfig canon; + readStringArray(canonObj, "stripParams", canon.stripParams); + readStringArray(canonObj, "forPathPrefixes", canon.devPathPrefixes); + readStringArray(canonObj, "preserveQueryFor", canon.preserveQueryPrefixes); + SetCanonicalizationConfig(std::move(canon)); + } + } +} + +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(@"[ns:runtime invalidateModules] 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); +} + +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); +} + +// ns:runtime `setDevBootComplete(value?: boolean)` — the JS dev client calls +// this (with `true`, or no argument) once the real app root view has +// committed. It flips both the JS-visible `__NS_HMR_BOOT_COMPLETE__` +// global and the native atomic that disarms the cold-boot runloop pump. +// The client may also pass `false` before a full JS-realm reload to +// re-arm the boot-time behaviors. +void SetDevBootCompleteCallback(const v8::FunctionCallbackInfo& info) { + v8::Isolate* isolate = info.GetIsolate(); + v8::HandleScope scope(isolate); + v8::Local ctx = isolate->GetCurrentContext(); + + bool value = true; + if (info.Length() >= 1 && !info[0]->IsUndefined() && !info[0]->IsNull()) { + value = info[0]->BooleanValue(isolate); + } + + tns::SetDevBootComplete(isolate, ctx, value); +} + +} // namespace + +bool BuildNsRuntimeBinding(v8::Local context, v8::Local binding) { + v8::Isolate* isolate = v8::Isolate::GetCurrent(); + + InstallDevFunction(isolate, context, binding, "configureRuntime", ConfigureDevRuntimeCallback); + InstallDevFunction(isolate, context, binding, "invalidateModules", InvalidateModulesCallback); + InstallDevFunction(isolate, context, binding, "getLoadedModuleUrls", GetLoadedModuleUrlsCallback); + InstallDevFunction(isolate, context, binding, "setDevBootComplete", SetDevBootCompleteCallback); + + // Main-realm only: terminating workers from inside a worker would let + // a stuck worker take down its peers (see Worker.h). A worker realm's + // `ns:runtime` simply has no such member, so feature checks work. + if (!Caches::Get(isolate)->isWorker) { + InstallDevFunction(isolate, context, binding, "terminateAllWorkers", + Worker::TerminateAllWorkersCallback); + } + + if (RuntimeConfig.IsDebug) { + // Debug-only diagnostic: expose the HTTP canonical-key function to JS so + // the test harness can pin its identity behavior across cache-busters + // and dev-endpoint query normalization. + auto canonicalizeCb = [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + if (info.Length() < 1 || !info[0]->IsString()) { + info.GetReturnValue().SetEmptyString(); + return; + } + v8::String::Utf8Value u(iso, info[0]); + std::string key = CanonicalizeHttpUrlKey(*u ? std::string(*u) : std::string()); + info.GetReturnValue().Set(tns::ToV8String(iso, key.c_str())); + }; + v8::Local fn; + if (v8::Function::New(context, canonicalizeCb).ToLocal(&fn)) { + fn->SetName(tns::ToV8String(isolate, "canonicalizeHttpUrlKey")); + if (!binding + ->CreateDataProperty(context, tns::ToV8String(isolate, "canonicalizeHttpUrlKey"), fn) + .FromMaybe(false)) { + return false; + } + } + } + + return true; +} + +} // namespace tns diff --git a/NativeScript/runtime/ModuleInternal.h b/NativeScript/runtime/ModuleInternal.h index 1b835979..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); @@ -39,6 +46,9 @@ class ModuleInternal { const std::string& moduleName); std::string ResolvePathFromPackageJson(const std::string& packageJson, bool& error); + v8::Local CreatePlaceholderModule(v8::Isolate* isolate, + const std::string& moduleName, + const std::string& cacheKey); static v8::ScriptCompiler::CachedData* LoadScriptCache( const std::string& path); static void SaveScriptCache(const v8::Local script, diff --git a/NativeScript/runtime/ModuleInternal.mm b/NativeScript/runtime/ModuleInternal.mm index a864298a..6396d5a0 100644 --- a/NativeScript/runtime/ModuleInternal.mm +++ b/NativeScript/runtime/ModuleInternal.mm @@ -4,10 +4,12 @@ #include #include #include +#include #include #include "BuiltinLoader.h" #include "Caches.h" #include "DevFlags.h" +#include "HMRSupport.h" #include "Helpers.h" #include "ModuleInternalCallbacks.h" // for ResolveModuleCallback #include "NativeScriptException.h" @@ -19,12 +21,91 @@ namespace tns { +// require()-path policy only: import() rejects a missing bare specifier outright +// (ESM optionality is `try { await import(x) } catch {}` at the call site, and in +// dev sessions a bare specifier the import map doesn't cover is a config bug that +// must fail loudly — see docs/knowledge/hmr-simplification-pass.md §2). +static bool IsLikelyOptionalModule(const std::string& moduleName) { + // Node built-ins are handled by their own dedicated resolution path; never treat them as + // an optional external module. + if (moduleName.rfind("node:", 0) == 0) { + return false; + } + + // Check if it's a bare module name (no path separators) that could be an npm package. + // + // Bare specifiers that end in a recognizable script/data extension (e.g. "foo.js", + // "config.json") are explicit file references, not npm-style package names — real npm + // package names don't carry a file extension. Treating them as "likely optional" would + // swallow a genuine "module not found" failure behind a lazily-throwing placeholder + // instead of letting require()/import() fail immediately, which is what callers (and the + // existing "should throw error if cant find node module" test) expect for those names. + // + // This carve-out is deliberately narrow: a dotted bare name that doesn't end in one of + // these exact extensions (e.g. "lodash.debounce") is still treated as optional, same as + // before. See ModuleInternal.mm/ModuleInternalCallbacks.mm optional-module tests for the + // cases this boundary is expected to hold for. + static const char* kExplicitFileExtensions[] = {".js", ".mjs", ".cjs", ".json", ".node", ".ts"}; + + if (moduleName.find('/') == std::string::npos && moduleName.find('\\') == std::string::npos && + moduleName[0] != '.' && moduleName[0] != '~' && moduleName[0] != '/') { + for (const char* ext : kExplicitFileExtensions) { + size_t extLen = strlen(ext); + if (moduleName.size() > extLen && + moduleName.compare(moduleName.size() - extLen, extLen, ext) == 0) { + return false; + } + } + return true; + } + return false; +} + // Helper function to check if a file path is an ES module (.mjs) but not a source map (.mjs.map) bool IsESModule(const std::string& path) { return path.size() >= 4 && path.compare(path.size() - 4, 4, ".mjs") == 0 && !(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) { @@ -117,10 +198,21 @@ 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; @@ -137,20 +229,63 @@ bool IsESModule(const std::string& path) { } // ES module fast path - if (IsESModule(path)) { + if (IsESModule(path) || isHttpModule) { TryCatch tc(isolate); Local moduleNamespace; + if (isHttpModule && RuntimeConfig.IsDebug && IsScriptLoadingLogEnabled()) { + Log(@"[run-module][http-esm][begin] %s", NormalizeHttpModuleUrl(path).c_str()); + } try { moduleNamespace = ModuleInternal::LoadESModule(isolate, path); - } catch (NativeScriptException& ex) { - if (RuntimeConfig.IsDebug) { + } catch (const NativeScriptException& ex) { + if (isHttpModule && RuntimeConfig.IsDebug && IsScriptLoadingLogEnabled()) { + Log(@"[run-module][http-esm][exception] %s message=%s", + NormalizeHttpModuleUrl(path).c_str(), ex.getMessage().c_str()); + } + if (RuntimeConfig.IsDebug && !isHttpModule) { + Log(@"***** JavaScript exception occurred - detailed stack trace follows *****"); Log(@"Error loading ES module: %s", path.c_str()); Log(@"Exception: %s", ex.getMessage().c_str()); + Log(@"***** End stack trace - continuing execution *****"); + Log(@"Debug mode - ES module loading failed, but telling iOS it succeeded to prevent app " + @"termination"); + return true; // avoid termination in debug + } else { + // Surface the inner exception's message so callers passing + // `outErrorMessage` see the real cause instead of just a + // false return. + SetOutErrorMessage(outErrorMessage, ex.getMessage()); + return false; } - ex.ReThrowToV8(isolate); - return false; } - return true; + if (moduleNamespace.IsEmpty()) { + if (isHttpModule && RuntimeConfig.IsDebug && IsScriptLoadingLogEnabled()) { + Log(@"[run-module][http-esm][empty] %s", NormalizeHttpModuleUrl(path).c_str()); + } + if (RuntimeConfig.IsDebug && !isHttpModule) { + Log(@"Debug mode - ES module returned empty namespace, but telling iOS it succeeded"); + return true; + } else { + // `LoadESModule` returned an empty value without throwing — + // typically a HTTP TLA timeout / rejection swallowed by the + // debug-modal path. Provide a directional hint so the JS + // rejection isn't empty; this is the only case where we + // *don't* have the actual reason text (see the rejection + // throw additions in `LoadESModule` to surface real causes + // when possible). + SetOutErrorMessage(outErrorMessage, + std::string("ES module returned empty namespace for ") + path + + " — likely top-level await timeout or rejection swallowed by " + "debug error modal; check the device console for the matching " + "[esm][evaluate][promise-rejected:detail] or " + "[esm][evaluate][promise-timeout] entry."); + return false; + } + } + if (isHttpModule && RuntimeConfig.IsDebug && IsScriptLoadingLogEnabled()) { + Log(@"[run-module][http-esm][ok] %s", NormalizeHttpModuleUrl(path).c_str()); + } + return true; // ES module loaded successfully } // For CommonJS modules (.js), use the traditional require() approach @@ -158,6 +293,7 @@ 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(); @@ -169,18 +305,59 @@ bool IsESModule(const std::string& path) { success = requireFunc->Call(context, globalObject, 1, args).ToLocal(&result); if (!success || tc.HasCaught()) { - if (RuntimeConfig.IsDebug) { + // Main isolate stays alive in debug for HMR; worker isolates must surface + // the failure so `worker.onerror` fires (handled in the else branch). + if (RuntimeConfig.IsDebug && !cache->isWorker) { + Log(@"***** JavaScript exception occurred - detailed stack trace follows *****"); Log(@"Error in require() call:"); Log(@" Requested module: '%s'", path.c_str()); Log(@" Called from: %s", RuntimeConfig.ApplicationPath.c_str()); + if (tc.HasCaught()) { tns::LogError(isolate, tc); } + + Log(@"***** End stack trace - continuing execution *****"); + Log(@"Debug mode - Main script execution failed, but telling iOS it succeeded to prevent " + @"app termination"); + + // Add a small delay to ensure error modal has time to render before we return + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.3 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + Log(@"🛡️ Debug mode - Crash prevention complete, app should remain stable"); + }); + + return true; // LIE TO iOS - return success to prevent app termination + } else { + // Best-effort extract the V8 exception text so the rejection + // upstream isn't empty. Leaves the out-param empty when the + // TryCatch has no exception to stringify; callers that need a + // placeholder string are expected to substitute one themselves. + std::string requireFailureMessage; + if (tc.HasCaught()) { + Local ex = tc.Exception(); + if (!ex.IsEmpty()) { + v8::Local exStr; + if (ex->ToString(context).ToLocal(&exStr)) { + v8::String::Utf8Value utf8(isolate, exStr); + if (*utf8) { + requireFailureMessage.assign(*utf8, utf8.length()); + } + } + } + } + if (requireFailureMessage.empty()) { + requireFailureMessage = std::string("require() failed for module ") + path; + } + SetOutErrorMessage(outErrorMessage, requireFailureMessage); + // For worker isolates, keep the V8 exception pending so the worker entry's + // TryCatch (Worker.mm) catches it and routes it to worker.onerror. The + // main isolate's release path is unchanged (no rethrow). + if (cache->isWorker && tc.HasCaught()) { + tc.ReThrow(); + } + return false; } - if (tc.HasCaught()) { - tc.ReThrow(); - } - return false; } return success; @@ -200,13 +377,33 @@ bool IsESModule(const std::string& path) { bool success = requireFuncFactory->Call(context, thiz, 2, args).ToLocal(&result); if (!success || tc.HasCaught()) { if (tc.HasCaught()) { - throw NativeScriptException(isolate, tc, "Failed to call require factory function"); + tns::LogError(isolate, tc); } - throw NativeScriptException(isolate, "Failed to call require factory function"); + Log(@"FATAL: Failed to call require factory function"); + // Return a dummy function to avoid further crashes + result = v8::Function::New(context, [](const v8::FunctionCallbackInfo& info) { + if (RuntimeConfig.IsDebug) { + Log(@"Debug mode - Require function unavailable (factory failed)"); + info.GetReturnValue().SetUndefined(); + } else { + info.GetIsolate()->ThrowException(v8::Exception::Error( + tns::ToV8String(info.GetIsolate(), "Require function unavailable"))); + } + }).ToLocalChecked(); } if (result.IsEmpty() || !result->IsFunction()) { - throw NativeScriptException(isolate, "Require factory did not return a function"); + Log(@"FATAL: Require factory did not return a function"); + // Return a dummy function + result = v8::Function::New(context, [](const v8::FunctionCallbackInfo& info) { + if (RuntimeConfig.IsDebug) { + Log(@"Debug mode - Require function unavailable (no function returned)"); + info.GetReturnValue().SetUndefined(); + } else { + info.GetIsolate()->ThrowException(v8::Exception::Error( + tns::ToV8String(info.GetIsolate(), "Require function unavailable"))); + } + }).ToLocalChecked(); } return result.As(); @@ -404,6 +601,14 @@ bool IsESModule(const std::string& path) { } if (path.empty()) { + // A bare specifier shaped like an npm package name resolves to a + // lazily-throwing placeholder, so an app can ship without an optional + // dependency installed and only fail if it actually touches it. A + // specifier resolved against "/" is an explicit absolute path, never an + // optional package, so it always hard-fails. + if (baseDir != "/" && IsLikelyOptionalModule(moduleName)) { + return this->CreatePlaceholderModule(isolate, moduleName, cacheKey); + } throw NativeScriptException(isolate, "Cannot find module '" + moduleName + "'", "Error"); } @@ -461,13 +666,62 @@ bool IsESModule(const std::string& path) { // Compile/load the JavaScript/ESM source Local scriptValue = LoadScript(isolate, modulePath); + // Check if script loading failed (debug mode graceful returns) + if (scriptValue.IsEmpty()) { + if (RuntimeConfig.IsDebug) { + // NSLog(@"Debug mode - Script loading returned empty value, returning gracefully: %s", + // modulePath.c_str()); + return Local(); + } else { + throw NativeScriptException(isolate, "Script loading failed for " + modulePath); + } + } + // Check if this is an ES module bool isESM = IsESModule(modulePath); std::shared_ptr cache = Caches::Get(isolate); if (isESM) { + // For ES modules, the returned value is the namespace object + + // First check if scriptValue is empty (from debug mode graceful returns) + if (scriptValue.IsEmpty()) { + if (RuntimeConfig.IsDebug) { + Log(@"Debug mode - ES module returned empty value, returning gracefully: %s", + modulePath.c_str()); + return Local(); + } else { + throw NativeScriptException(isolate, "ES module load returned empty value " + modulePath); + } + } + if (!scriptValue->IsObject()) { - throw NativeScriptException(isolate, "Failed to load ES module " + modulePath); + if (RuntimeConfig.IsDebug) { + Log(@"Debug mode - ES module load failed, returning gracefully: %s", modulePath.c_str()); + // Return empty module object to prevent crashes + return Local(); + } else { + throw NativeScriptException(isolate, "Failed to load ES module " + modulePath); + } + } + + // Debug: Check if we're in a worker context and if self.onmessage is set + std::shared_ptr cache = Caches::Get(isolate); + if (cache->isWorker) { + Local context = isolate->GetCurrentContext(); + Local global = context->Global(); + + // Check if self exists + Local selfValue; + if (global->Get(context, ToV8String(isolate, "self")).ToLocal(&selfValue)) { + if (selfValue->IsObject()) { + Local selfObj = selfValue.As(); + Local onmessageValue; + if (selfObj->Get(context, ToV8String(isolate, "onmessage")).ToLocal(&onmessageValue)) { + // onmessage exists + } + } + } } // Handle exports differently for ES modules vs worker scripts @@ -595,19 +849,63 @@ throw NativeScriptException(isolate, Local