Skip to content

Commit 708fecd

Browse files
committed
feat: HMR robustness and additional tests
1 parent 6c9e8b1 commit 708fecd

8 files changed

Lines changed: 207 additions & 143 deletions

File tree

test-app/app/src/main/assets/app/mainpage.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,5 @@ require("./tests/testConcurrentAccess");
7878

7979
require("./tests/testESModules.mjs");
8080
require("./tests/testHmrHotDataExt.mjs");
81+
require("./tests/testHttpCanonicalKey.mjs");
8182
require("./tests/testNodeBuiltinsAndOptionalModules.mjs");
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// HTTP canonical-key identity tests.
2+
//
3+
// Pins the behavior of the native CanonicalizeHttpUrlKey (the loader/registry
4+
// key) via the debug-only __nsCanonicalizeHttpUrlKey diagnostic global. Pure
5+
// string logic — no dev server required. Android does NOT collapse the
6+
// __ns_boot__/__ns_hmr__ virtual prefixes here (that is canonicalHotKey's job),
7+
// which these specs assert explicitly.
8+
9+
describe("HTTP canonical key", function () {
10+
function canon(url) {
11+
return globalThis.__nsCanonicalizeHttpUrlKey(url);
12+
}
13+
14+
it("is available in dev builds", function () {
15+
if (typeof globalThis.__nsCanonicalizeHttpUrlKey !== "function") {
16+
pending("__nsCanonicalizeHttpUrlKey not available (release build?)");
17+
return;
18+
}
19+
expect(typeof globalThis.__nsCanonicalizeHttpUrlKey).toBe("function");
20+
});
21+
22+
it("drops the fragment and unwraps file://http wrappers", function () {
23+
if (typeof globalThis.__nsCanonicalizeHttpUrlKey !== "function") { pending("release"); return; }
24+
expect(canon("http://h/ns/m/foo.js#frag")).toBe("http://h/ns/m/foo.js");
25+
expect(canon("file://http://h/x.js")).toBe("http://h/x.js");
26+
});
27+
28+
it("normalizes versioned bridge endpoints but not deeper paths", function () {
29+
if (typeof globalThis.__nsCanonicalizeHttpUrlKey !== "function") { pending("release"); return; }
30+
expect(canon("http://h/ns/rt/42")).toBe("http://h/ns/rt");
31+
expect(canon("http://h/ns/core/13")).toBe("http://h/ns/core");
32+
expect(canon("http://h/ns/rt/42/x.js")).toBe("http://h/ns/rt/42/x.js");
33+
});
34+
35+
it("does NOT collapse __ns_hmr__/__ns_boot__ prefixes (Android loader key)", function () {
36+
if (typeof globalThis.__nsCanonicalizeHttpUrlKey !== "function") { pending("release"); return; }
37+
expect(canon("http://h/ns/m/__ns_hmr__/v7/foo.js"))
38+
.toBe("http://h/ns/m/__ns_hmr__/v7/foo.js");
39+
});
40+
41+
it("strips ?import and sorts remaining query params", function () {
42+
if (typeof globalThis.__nsCanonicalizeHttpUrlKey !== "function") { pending("release"); return; }
43+
expect(canon("http://h/a?import=1&b=2&a=3")).toBe("http://h/a?a=3&b=2");
44+
expect(canon("http://h/a?b=2&a=1")).toBe("http://h/a?a=1&b=2");
45+
expect(canon("http://h/ns/core?import=1")).toBe("http://h/ns/core");
46+
});
47+
48+
it("leaves a non-http(s) specifier unchanged", function () {
49+
if (typeof globalThis.__nsCanonicalizeHttpUrlKey !== "function") { pending("release"); return; }
50+
expect(canon("~/local/foo.js")).toBe("~/local/foo.js");
51+
});
52+
});

test-app/runtime/src/main/cpp/DevFlags.cpp

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,25 @@ static bool s_allowRemoteModules = false;
106106
static std::vector<std::string> s_remoteModuleAllowlist;
107107
static bool s_isDebuggable = false;
108108

109-
// Helper to check if a URL starts with a given prefix
110-
static bool UrlStartsWith(const std::string& url, const std::string& prefix) {
111-
if (prefix.size() > url.size()) return false;
112-
return url.compare(0, prefix.size(), prefix) == 0;
109+
// Returns true when `url` is covered by allowlist `entry`, matching only on
110+
// URL component boundaries so lookalike-host and lookalike-port values are
111+
// refused: an entry of "https://cdn.example.com" does NOT authorize
112+
// "https://cdn.example.com.attacker.com/x.js" or
113+
// "https://cdn.example.com:9999/x.js". The character after the matched entry
114+
// text must be a path/query/fragment boundary ('/', '?', '#'), or the URL must
115+
// end exactly at the entry, or the entry must already end in '/'.
116+
//
117+
// Deny-by-default: an entry without an explicit port does not match a URL
118+
// that adds one. To allow a specific port, include it in the entry.
119+
static bool RemoteUrlMatchesAllowlistEntry(const std::string& url,
120+
const std::string& entry) {
121+
if (entry.empty()) return false;
122+
if (url.size() < entry.size()) return false;
123+
if (url.compare(0, entry.size(), entry) != 0) return false;
124+
if (url.size() == entry.size()) return true; // exact match
125+
if (entry.back() == '/') return true; // entry ended at a boundary
126+
const char next = url[entry.size()];
127+
return next == '/' || next == '?' || next == '#';
113128
}
114129

115130
void InitializeSecurityConfig() {
@@ -195,9 +210,9 @@ bool IsRemoteUrlAllowed(const std::string& url) {
195210
return true;
196211
}
197212

198-
// Check if URL matches any allowlist prefix
199-
for (const std::string& prefix : s_remoteModuleAllowlist) {
200-
if (UrlStartsWith(url, prefix)) {
213+
// Check if URL matches any allowlist entry on a component boundary
214+
for (const std::string& entry : s_remoteModuleAllowlist) {
215+
if (RemoteUrlMatchesAllowlistEntry(url, entry)) {
201216
return true;
202217
}
203218
}

test-app/runtime/src/main/cpp/HMRSupport.cpp

Lines changed: 78 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ namespace tns {
3131
// definitions live in ModuleInternalCallbacks.cpp; this header-style
3232
// forward block lets HMRSupport.cpp call them without pulling the
3333
// resolver header in (avoids a circular include).
34-
void SetImportMap(const std::string& json);
34+
void SetImportMapEntries(const std::vector<std::pair<std::string, std::string>>& entries);
3535
void SetVolatilePatterns(const std::vector<std::string>& patterns);
3636
std::vector<std::string> GetLoadedModuleUrls();
3737

@@ -178,10 +178,53 @@ static bool IsSupportedDevSessionPlatform(const std::string& platform) {
178178
return platform == "android";
179179
}
180180

181+
// Parse an import-map value (a JSON string OR a JS object of shape
182+
// `{ imports: { "<key>": "<url>", ... } }`) into flat (key → URL) entries using
183+
// V8's own JSON/object model. Returns true if it found an `imports` object
184+
// (even if empty); false if the value is unusable. Only flat string key→URL
185+
// mappings are honored; non-string import values are skipped.
186+
static bool ReadImportMapEntries(v8::Isolate* isolate,
187+
v8::Local<v8::Context> context,
188+
v8::Local<v8::Value> importMapValue,
189+
std::vector<std::pair<std::string, std::string>>* out) {
190+
v8::Local<v8::Value> mapVal = importMapValue;
191+
if (mapVal->IsString()) {
192+
v8::Local<v8::Value> parsed;
193+
if (!v8::JSON::Parse(context, mapVal.As<v8::String>()).ToLocal(&parsed)) {
194+
return false;
195+
}
196+
mapVal = parsed;
197+
}
198+
if (!mapVal->IsObject()) return false;
199+
v8::Local<v8::Object> mapObj = mapVal.As<v8::Object>();
200+
201+
v8::Local<v8::Value> importsVal;
202+
if (!mapObj->Get(context, ToV8String(isolate, "imports")).ToLocal(&importsVal) ||
203+
!importsVal->IsObject()) {
204+
return false;
205+
}
206+
v8::Local<v8::Object> imports = importsVal.As<v8::Object>();
207+
v8::Local<v8::Array> keys;
208+
if (!imports->GetOwnPropertyNames(context).ToLocal(&keys)) return false;
209+
210+
for (uint32_t i = 0; i < keys->Length(); ++i) {
211+
v8::Local<v8::Value> keyVal;
212+
if (!keys->Get(context, i).ToLocal(&keyVal)) continue;
213+
v8::Local<v8::Value> valVal;
214+
if (!imports->Get(context, keyVal).ToLocal(&valVal) || !valVal->IsString()) continue;
215+
v8::String::Utf8Value keyUtf8(isolate, keyVal);
216+
v8::String::Utf8Value valUtf8(isolate, valVal);
217+
if (*keyUtf8 && *valUtf8) {
218+
out->emplace_back(std::string(*keyUtf8), std::string(*valUtf8));
219+
}
220+
}
221+
return true;
222+
}
223+
181224
// Apply the v8::Object payload of `__nsConfigureDevRuntime`: re-validate the
182-
// `importMap` shape and serialize it back to JSON for `SetImportMap`. Parsing
183-
// runs entirely in V8 (via `ConfigureDevRuntimeCallback`), so this is a thin
184-
// wrapper over that shared validation.
225+
// `importMap` shape and read its entries via `ReadImportMapEntries` (V8-based
226+
// parsing) into the process-wide map. Mirrors the import-map handling in
227+
// `ConfigureDevRuntimeCallback`.
185228
static bool ApplyDevRuntimeConfigObject(v8::Isolate* isolate,
186229
v8::Local<v8::Context> context,
187230
v8::Local<v8::Object> payload,
@@ -202,49 +245,20 @@ static bool ApplyDevRuntimeConfigObject(v8::Isolate* isolate,
202245
return false;
203246
}
204247

205-
// Use JSON.stringify on the importMap object — keeps the on-disk format
206-
// identical to what `__nsConfigureRuntime` already accepts.
207-
v8::Local<v8::Object> jsonObj;
208-
v8::Local<v8::Value> globalJson;
209-
if (!context->Global()->Get(context, ToV8String(isolate, "JSON")).ToLocal(&globalJson) ||
210-
!globalJson->IsObject()) {
211-
if (errorMessage != nullptr) {
212-
*errorMessage = "[__nsStartDevSession] JSON global unavailable";
213-
}
214-
return false;
215-
}
216-
jsonObj = globalJson.As<v8::Object>();
217-
218-
v8::Local<v8::Value> stringifyFnVal;
219-
if (!jsonObj->Get(context, ToV8String(isolate, "stringify")).ToLocal(&stringifyFnVal) ||
220-
!stringifyFnVal->IsFunction()) {
221-
if (errorMessage != nullptr) {
222-
*errorMessage = "[__nsStartDevSession] JSON.stringify unavailable";
223-
}
224-
return false;
225-
}
226-
227-
v8::Local<v8::Function> stringifyFn = stringifyFnVal.As<v8::Function>();
228-
v8::Local<v8::Value> args[] = {importMapValue};
229-
v8::MaybeLocal<v8::Value> maybeJson = stringifyFn->Call(context, jsonObj, 1, args);
230-
v8::Local<v8::Value> jsonVal;
231-
if (!maybeJson.ToLocal(&jsonVal) || !jsonVal->IsString()) {
248+
std::vector<std::pair<std::string, std::string>> importEntries;
249+
if (!ReadImportMapEntries(isolate, context, importMapValue, &importEntries)) {
232250
if (errorMessage != nullptr) {
233-
*errorMessage = "[__nsStartDevSession] failed to serialize importMap";
251+
*errorMessage = "[__nsStartDevSession] failed to read importMap";
234252
}
235253
return false;
236254
}
237-
238-
v8::String::Utf8Value jsonUtf8(isolate, jsonVal);
239-
std::string importMapJson = *jsonUtf8 ? *jsonUtf8 : "";
240-
if (importMapJson.empty()) {
255+
if (importEntries.empty()) {
241256
if (errorMessage != nullptr) {
242257
*errorMessage = "[__nsStartDevSession] runtime config importMap was empty";
243258
}
244259
return false;
245260
}
246-
247-
SetImportMap(importMapJson);
261+
SetImportMapEntries(importEntries);
248262

249263
std::vector<std::string> patterns;
250264
v8::Local<v8::Value> volatilePatternsValue;
@@ -447,14 +461,10 @@ bool ApplyDevRuntimeConfigFromUrl(const std::string& url,
447461
return true;
448462
}
449463

450-
// Native-side mirror of `__NS_HMR_BOOT_COMPLETE__`. Read by the
451-
// runloop pump in `MaybePumpJSThreadDuringBoot` so its gate is a
452-
// single relaxed atomic load on the HMR-time hot path.
453-
static std::atomic<bool> g_devSessionBootComplete{false};
454-
455-
static inline bool IsDevSessionBootComplete() {
456-
return g_devSessionBootComplete.load(std::memory_order_relaxed);
457-
}
464+
// The live "dev-session boot complete" signal is the JS global
465+
// __NS_HMR_BOOT_COMPLETE__, set by ApplyDevSessionGlobals /
466+
// SetDevSessionBootComplete below. (There is no native runloop pump on
467+
// Android — the main NativeScript isolate runs JS on the UI thread.)
458468

459469
void ApplyDevSessionGlobals(v8::Isolate* isolate,
460470
v8::Local<v8::Context> context,
@@ -464,7 +474,6 @@ void ApplyDevSessionGlobals(v8::Isolate* isolate,
464474
SetBooleanGlobal(isolate, context, "__NS_HMR_BOOT_COMPLETE__", false);
465475
SetBooleanGlobal(isolate, context, "__NS_HMR_CLIENT_ACTIVE__", false);
466476
SetBooleanGlobal(isolate, context, "__NS_HMR_BROWSER_RUNTIME_CLIENT_ACTIVE__", false);
467-
g_devSessionBootComplete.store(false, std::memory_order_relaxed);
468477
if (IsScriptLoadingLogEnabled()) {
469478
DEBUG_WRITE("[dev-session] globals applied session=%s origin=%s ws=%s bootComplete=false",
470479
session.sessionId.c_str(), session.origin.c_str(),
@@ -476,7 +485,6 @@ void SetDevSessionBootComplete(v8::Isolate* isolate,
476485
v8::Local<v8::Context> context,
477486
bool value) {
478487
SetBooleanGlobal(isolate, context, "__NS_HMR_BOOT_COMPLETE__", value);
479-
g_devSessionBootComplete.store(value, std::memory_order_relaxed);
480488
if (IsScriptLoadingLogEnabled()) {
481489
DEBUG_WRITE("[dev-session] __NS_HMR_BOOT_COMPLETE__=%s",
482490
value ? "true" : "false");
@@ -2439,32 +2447,12 @@ void ConfigureDevRuntimeCallback(const v8::FunctionCallbackInfo<v8::Value>& info
24392447
v8::Local<v8::Value> importMapVal;
24402448
if (config->Get(ctx, ToV8String(isolate, "importMap")).ToLocal(&importMapVal) &&
24412449
!importMapVal->IsUndefined() && !importMapVal->IsNull()) {
2442-
std::string jsonStr;
2443-
if (importMapVal->IsString()) {
2444-
v8::String::Utf8Value utf8(isolate, importMapVal);
2445-
if (*utf8) jsonStr = *utf8;
2446-
} else if (importMapVal->IsObject()) {
2447-
v8::Local<v8::Value> jsonGlobalVal;
2448-
if (ctx->Global()->Get(ctx, ToV8String(isolate, "JSON")).ToLocal(&jsonGlobalVal) &&
2449-
jsonGlobalVal->IsObject()) {
2450-
v8::Local<v8::Object> jsonObj = jsonGlobalVal.As<v8::Object>();
2451-
v8::Local<v8::Value> stringifyVal;
2452-
if (jsonObj->Get(ctx, ToV8String(isolate, "stringify")).ToLocal(&stringifyVal) &&
2453-
stringifyVal->IsFunction()) {
2454-
v8::Local<v8::Function> stringify = stringifyVal.As<v8::Function>();
2455-
v8::Local<v8::Value> args[] = {importMapVal};
2456-
v8::Local<v8::Value> result;
2457-
if (stringify->Call(ctx, jsonObj, 1, args).ToLocal(&result) && result->IsString()) {
2458-
v8::String::Utf8Value utf8(isolate, result);
2459-
if (*utf8) jsonStr = *utf8;
2460-
}
2461-
}
2462-
}
2463-
}
2464-
if (!jsonStr.empty()) {
2465-
SetImportMap(jsonStr);
2450+
std::vector<std::pair<std::string, std::string>> importEntries;
2451+
if (ReadImportMapEntries(isolate, ctx, importMapVal, &importEntries) &&
2452+
!importEntries.empty()) {
2453+
SetImportMapEntries(importEntries);
24662454
if (logScriptLoading) {
2467-
DEBUG_WRITE("[__nsConfigureRuntime] import map set (%zu bytes)", jsonStr.size());
2455+
DEBUG_WRITE("[__nsConfigureRuntime] import map set (%zu entries)", importEntries.size());
24682456
}
24692457
}
24702458
}
@@ -2909,6 +2897,22 @@ void ApplyStyleUpdateCallback(const v8::FunctionCallbackInfo<v8::Value>& info) {
29092897
}
29102898
}
29112899

2900+
// Debug-only diagnostic: expose CanonicalizeHttpUrlKey to JS so the test harness
2901+
// can pin its identity behavior. Not part of the @nativescript/vite client API.
2902+
// The whole installer is gated on isDebuggable at the call site, so this never
2903+
// ships in release.
2904+
void CanonicalizeHttpUrlKeyCallback(const v8::FunctionCallbackInfo<v8::Value>& info) {
2905+
v8::Isolate* isolate = info.GetIsolate();
2906+
v8::HandleScope scope(isolate);
2907+
if (info.Length() < 1 || !info[0]->IsString()) {
2908+
info.GetReturnValue().SetEmptyString();
2909+
return;
2910+
}
2911+
v8::String::Utf8Value u(isolate, info[0]);
2912+
std::string key = CanonicalizeHttpUrlKey(*u ? std::string(*u) : std::string());
2913+
info.GetReturnValue().Set(ToV8String(isolate, key.c_str()));
2914+
}
2915+
29122916
void GetLoadedModuleUrlsCallback(const v8::FunctionCallbackInfo<v8::Value>& info) {
29132917
v8::Isolate* isolate = info.GetIsolate();
29142918
v8::HandleScope scope(isolate);
@@ -2962,6 +2966,7 @@ void InitializeHmrDevGlobals(v8::Isolate* isolate, v8::Local<v8::Context> contex
29622966
InstallGlobalFunction(isolate, context, "__nsReloadDevApp", ReloadDevAppCallback);
29632967
InstallGlobalFunction(isolate, context, "__nsApplyStyleUpdate", ApplyStyleUpdateCallback);
29642968
InstallGlobalFunction(isolate, context, "__nsGetLoadedModuleUrls", GetLoadedModuleUrlsCallback);
2969+
InstallGlobalFunction(isolate, context, "__nsCanonicalizeHttpUrlKey", CanonicalizeHttpUrlKeyCallback);
29652970
}
29662971

29672972
void CleanupHMRGlobals() {

test-app/runtime/src/main/cpp/HMRSupport.h

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,9 +167,10 @@ void ClearHttpModulePrefetchCache();
167167
// synchronous network turn so the caller can pump its own runloop (e.g. the
168168
// JS-thread runloop so a placeholder UI can repaint during cold-boot).
169169
//
170-
// Default: a built-in pump that no-ops outside the JS thread / after the
171-
// dev-session boot completes (see `MaybePumpJSThreadDuringBoot` in
172-
// HMRSupport.cpp).
170+
// Default: a no-op (`NoopHttpFetchYield`). Android's main NativeScript
171+
// isolate runs JS on the UI thread, so there is no separate JS-thread
172+
// runloop to pump here; a host that drives its own loop can install a real
173+
// pump (e.g. one calling ALooper_pollOnce(0)) via this hook.
173174
//
174175
// Pass `nullptr` to disable any yielding (used by hosts that drive their own
175176
// run loop or by tests that want bit-for-bit deterministic fetch timing).

0 commit comments

Comments
 (0)