Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 48 additions & 3 deletions .github/workflows/npm_release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,12 @@ jobs:

test:
name: Test
runs-on: macos-14
# Runtime suite runs on Xcode 26 / iOS 26 — the combo verified passing
# locally. iOS 17.x is intentionally NOT used: a worker-teardown stress spec
# deadlocks the JS thread there (hangs the whole suite), a bug we don't chase
# on an OS we don't ship-test. Pinned (not latest-stable) so the runtime suite
# is deterministic; bump to "27" when Xcode 27 ships.
runs-on: macos-15
needs: build
steps:
- name: Harden the runner (Audit all outbound calls)
Expand Down Expand Up @@ -224,9 +229,49 @@ jobs:
# TestRunnerTests.swift) need more than 20m headroom per attempt.
timeout_minutes: 40
max_attempts: 2
command: set -o pipefail && xcodebuild -project v8ios.xcodeproj -scheme TestRunner -resultBundlePath $TEST_FOLDER/test_results -destination platform\=iOS\ Simulator,OS\=17.2,name\=iPhone\ 15\ Pro\ Max build test | xcpretty
command: set -o pipefail && xcodebuild -project v8ios.xcodeproj -scheme TestRunner -resultBundlePath $TEST_FOLDER/test_results -destination platform\=iOS\ Simulator,OS\=latest,name\=iPhone\ 16\ Pro build test | xcpretty
on_retry_command: rm -rf $TEST_FOLDER/test_results* && xcrun simctl shutdown all
new_command_on_retry: xcodebuild -project v8ios.xcodeproj -scheme TestRunner -resultBundlePath $TEST_FOLDER/test_results -destination platform\=iOS\ Simulator,OS\=17.2,name\=iPhone\ 15\ Pro\ Max build test
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
Expand Down
8 changes: 8 additions & 0 deletions NativeScript/runtime/DevFlags.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
57 changes: 50 additions & 7 deletions NativeScript/runtime/DevFlags.mm
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#import <Foundation/Foundation.h>

#include "DevFlags.h"
#include "Helpers.h"
#include "Runtime.h"
#include "RuntimeConfig.h"
#include <vector>
Expand All @@ -13,16 +14,58 @@ 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<std::string> 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 == '#';
Comment on lines +60 to +68

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Normalize paths before matching path-scoped allowlist entries.

Line 101 authorizes any raw URL with a trailing-slash entry prefix, so an allowlist entry like https://cdn.example.com/app/ also matches https://cdn.example.com/app/../admin.js. If the fetch layer or server normalizes dot segments, this escapes the intended path scope. Parse/canonicalize the URL path before matching, or reject encoded/plain dot segments before applying the prefix rule.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@NativeScript/runtime/DevFlags.mm` around lines 95 - 103, The allowlist check
in RemoteUrlMatchesAllowlistEntry is matching raw URL prefixes too early, which
lets path-scoped entries be bypassed with dot-segment paths. Update the matching
logic to canonicalize or normalize the URL path before applying the
prefix/boundary rules, or explicitly reject plain/encoded dot segments in
DevFlags.mm so a trailing-slash allowlist entry cannot match escaped paths.

}

void InitializeSecurityConfig() {
Expand Down Expand Up @@ -84,9 +127,9 @@ bool IsRemoteUrlAllowed(const std::string& url) {
return true;
}

// Check if URL matches any allowlist prefix
for (const std::string& prefix : s_remoteModuleAllowlist) {
if (UrlStartsWith(url, prefix)) {
// Check if URL matches any allowlist entry on a URL-component boundary.
for (const std::string& entry : s_remoteModuleAllowlist) {
if (RemoteUrlMatchesAllowlistEntry(url, entry)) {
return true;
}
}
Expand Down
158 changes: 127 additions & 31 deletions NativeScript/runtime/HMRSupport.h
Original file line number Diff line number Diff line change
Expand Up @@ -11,51 +11,147 @@ template <class T> class Local;
class Object;
class Function;
class Context;
class Value;
}

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
// (V8 10.3.22's ResolveModuleCallback is synchronous, so the fetch
// must be native),
// - a body prewarm cache + list-mode kickstart so a server-computed
// module closure can be fetched in one parallel wave before V8's
// serial synchronous walk,
// - eviction plumbing (prefetch-cache evict + an eviction-driven
// fetch nonce that defeats 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<v8::Object> 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<v8::Function> cb);
void RegisterHotDispose(v8::Isolate* isolate, const std::string& key, v8::Local<v8::Function> cb);

// Optional: expose read helpers (may be useful for debugging/integration)
std::vector<v8::Local<v8::Function>> GetHotAcceptCallbacks(v8::Isolate* isolate, const std::string& key);
std::vector<v8::Local<v8::Function>> 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<v8::Context> context,
v8::Local<v8::Object> 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
//
// On a fast path, returns from the in-memory kickstart-prewarm cache
// without touching the network (destructive one-shot read). On the slow
// path, performs a synchronous fetch with one retry.
bool HttpFetchText(const std::string& url, std::string& out, std::string& contentType, int& status);

// Drop all entries in the prewarm cache. Safe to call from any thread.
// Used by Runtime teardown and by HMR cache-poison scenarios where the
// dev server has indicated a graph version bump.
void ClearHttpModulePrefetchCache();

// Register a "yield" callback that `HttpFetchText` should invoke around its
// synchronous network turn so the caller can pump its own runloop (e.g. the
// JS-thread runloop so a placeholder UI can repaint during cold-boot).
//
// Default: a 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)());

// Drop a specific URL set from the prewarm cache. Safe to call from any
// thread; missing keys are silently ignored. Used by `InvalidateModules`
// so that an HMR eviction also purges any stale HTTP body a previous
// kickstart wave left behind. Without this, the kickstart's cache plus
// `HttpFetchText`'s destructive-read fast path would happily serve V8 a
// stale body from the prior save — visible to the user as a 1-cycle lag
// between save and visual update.
void EvictHttpModulePrefetchCacheUrls(const std::vector<std::string>& urls);

// 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<std::string>& urls);

// List-mode kickstart prewarm. Fetches ONLY the explicit URL list it
// was given (no body scanning, no graph recursion — the dev server owns
// the module graph and supplies closures: `evictPaths` for HMR, an
// entry-graph crawl for cold boot). Fetches run in parallel (up to
// `maxConcurrent`), each body landing in the prewarm cache that
// `HttpFetchText` reads. Blocks the calling thread until the wave
// drains or `timeoutSeconds` elapses.
//
// By feeding the precomputed list we turn N sequential
// `LoadHttpModuleForUrl` calls (the importer chain during V8's
// ResolveModuleCallback walk) into a single parallel wave that
// completes before V8 starts walking.
//
// Cleared/blocked URLs are filtered up front; partial success is
// reported as success (the V8 walk falls back to per-module
// HttpFetchText for anything we couldn't pre-fill).
//
// `outFetchedCount` (optional) receives the number of distinct URLs
// fetched. `outElapsedMs` (optional) receives wall-clock time.
bool KickstartHmrPrefetchUrlsSync(const std::vector<std::string>& urls,
int maxConcurrent,
double timeoutSeconds,
size_t* outFetchedCount,
uint64_t* outElapsedMs);

// 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, kickstart pump-wait). Exposed to JS as
// `__NS_DEV__.setDevBootComplete(value?: boolean)`.
void SetDevBootComplete(v8::Isolate* isolate, v8::Local<v8::Context> context,
bool value);

// Clear process-wide dev-loader state (prewarm cache, cache-bust marks,
// boot-complete flag). 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();

// Mirror a globally-installed value onto `globalThis.<name>` so
// `globalThis.<name>` lookups resolve when the runtime installs the
// canonical value on the realm's global object.
void MirrorGlobalOnGlobalThis(v8::Isolate* isolate, v8::Local<v8::Context> context,
const char* name);

// ─────────────────────────────────────────────────────────────
// Dev host namespace installer
//
// Installs the single `__NS_DEV__` namespace object that carries every
// JS-callable dev primitive that any tooling can depend on.
// Idempotent per realm; safe to call from any place that has a fresh
// context + isolate scope. Installed on the realm's global object AND
// mirrored on globalThis.
//
// `__NS_DEV__` members:
// - configureRuntime(config) (import map + volatile patterns)
// - invalidateModules(urls) (registry + cache eviction)
// - kickstartPrefetch(urls, opts?) (parallel HTTP prewarm, list mode)
// - getLoadedModuleUrls() (registry introspection)
// - setDevBootComplete(value?) (boot-complete signal)
// - terminateAllWorkers() (main isolate only; see Worker.h)
// - canonicalizeHttpUrlKey(url) (debug builds only; test diagnostic)
void InitializeHmrDevGlobals(v8::Isolate* isolate, v8::Local<v8::Context> context,
bool isWorker);

} // namespace tns
Loading
Loading