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
27 changes: 18 additions & 9 deletions NativeScript/runtime/HMRSupport.h
Original file line number Diff line number Diff line change
Expand Up @@ -35,24 +35,33 @@ void RegisterHotDispose(v8::Isolate* isolate, const std::string& key, v8::Local<
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.
// `import.meta.hot` implementation
// Provides:
// - `hot.data` (per-module persistent object across HMR updates)
// - `hot.accept(...)` (deps argument currently ignored; registers callback if provided)
// - `hot.dispose(cb)` (registers disposer)
// - `hot.decline()` / `hot.invalidate()` (currently no-ops)
// - `hot.prune` (currently always false)
//
// Notes/limitations:
// - Event APIs (`hot.on/off`), messaging (`hot.send`), and status handling are not implemented.
// - `modulePath` is used to derive the per-module key for `hot.data` and callbacks.
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, normalizes known cache busters (e.g. t/v/import)
// and normalizes some versioned bridge paths.
// - For non-dev/public URLs, preserves the full query string as part of the cache key.
std::string CanonicalizeHttpUrlKey(const std::string& url);

// Minimal text fetch for dev HTTP ESM loader. Returns true on 2xx with non-empty body.
// Minimal text fetch for HTTP ESM loader. Returns true on 2xx with non-empty body.
// - out: response body
// - contentType: Content-Type header if present
// - status: HTTP status code
Expand Down
119 changes: 103 additions & 16 deletions NativeScript/runtime/HMRSupport.mm
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ static inline bool StartsWith(const std::string& s, const char* prefix) {
return s.size() >= n && s.compare(0, n, prefix) == 0;
}

static inline bool EndsWith(const std::string& s, const char* suffix) {
size_t n = strlen(suffix);
return s.size() >= n && s.compare(s.size() - n, n, suffix) == 0;
}

// Per-module hot data and callbacks. Keyed by canonical module path.
static std::unordered_map<std::string, v8::Global<v8::Object>> g_hotData;
static std::unordered_map<std::string, std::vector<v8::Global<v8::Function>>> g_hotAccept;
Expand Down Expand Up @@ -82,9 +87,67 @@ void InitializeImportMetaHot(v8::Isolate* isolate,
// Ensure context scope for property creation
v8::HandleScope scope(isolate);

// Canonicalize key to ensure per-module hot.data persists across HMR URLs.
// Important: this must NOT affect the HTTP loader cache key; otherwise HMR fetches
// can collapse onto an already-evaluated module and no update occurs.
auto canonicalHotKey = [&](const std::string& in) -> std::string {
// Unwrap file://http(s)://...
std::string s = in;
if (StartsWith(s, "file://http://") || StartsWith(s, "file://https://")) {
s = s.substr(strlen("file://"));
}

// Drop fragment
size_t hashPos = s.find('#');
if (hashPos != std::string::npos) s = s.substr(0, hashPos);

// Split query (we'll drop it for hot key stability)
size_t qPos = s.find('?');
std::string noQuery = (qPos == std::string::npos) ? s : s.substr(0, qPos);

// If it's an http(s) URL, normalize only the path portion below.
size_t schemePos = noQuery.find("://");
size_t pathStart = (schemePos == std::string::npos) ? 0 : noQuery.find('/', schemePos + 3);
if (pathStart == std::string::npos) {
// No path; return without query
return noQuery;
}

std::string origin = noQuery.substr(0, pathStart);
std::string path = noQuery.substr(pathStart);

// Normalize NS HMR virtual module paths:
// /ns/m/__ns_hmr__/<token>/<rest> -> /ns/m/<rest>
const char* hmrPrefix = "/ns/m/__ns_hmr__/";
size_t hmrLen = strlen(hmrPrefix);
if (path.compare(0, hmrLen, hmrPrefix) == 0) {
size_t nextSlash = path.find('/', hmrLen);
if (nextSlash != std::string::npos) {
path = std::string("/ns/m/") + path.substr(nextSlash + 1);
}
}

// Normalize common script extensions so `/foo` and `/foo.ts` share hot.data.
const char* exts[] = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"};
for (auto ext : exts) {
if (EndsWith(path, ext)) {
path = path.substr(0, path.size() - strlen(ext));
break;
}
}

// Also drop `.vue`? No — SFC endpoints should stay distinct.
return origin + path;
};

const std::string key = canonicalHotKey(modulePath);
if (tns::IsScriptLoadingLogEnabled() && key != modulePath) {
Log(@"[hmr] canonical key: %s -> %s", modulePath.c_str(), key.c_str());
}

// Helper to capture key in function data
auto makeKeyData = [&](const std::string& key) -> Local<Value> {
return tns::ToV8String(isolate, key.c_str());
auto makeKeyData = [&](const std::string& k) -> Local<Value> {
return tns::ToV8String(isolate, k.c_str());
};

// accept([deps], cb?) — we register cb if provided; deps ignored for now
Expand Down Expand Up @@ -134,22 +197,22 @@ void InitializeImportMetaHot(v8::Isolate* isolate,
Local<Object> hot = Object::New(isolate);
// Stable flags
hot->CreateDataProperty(context, tns::ToV8String(isolate, "data"),
GetOrCreateHotData(isolate, modulePath)).Check();
GetOrCreateHotData(isolate, key)).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();
v8::Function::New(context, acceptCb, makeKeyData(key)).ToLocalChecked()).Check();
hot->CreateDataProperty(
context, tns::ToV8String(isolate, "dispose"),
v8::Function::New(context, disposeCb, makeKeyData(modulePath)).ToLocalChecked()).Check();
v8::Function::New(context, disposeCb, makeKeyData(key)).ToLocalChecked()).Check();
hot->CreateDataProperty(
context, tns::ToV8String(isolate, "decline"),
v8::Function::New(context, declineCb, makeKeyData(modulePath)).ToLocalChecked()).Check();
v8::Function::New(context, declineCb, makeKeyData(key)).ToLocalChecked()).Check();
hot->CreateDataProperty(
context, tns::ToV8String(isolate, "invalidate"),
v8::Function::New(context, invalidateCb, makeKeyData(modulePath)).ToLocalChecked()).Check();
v8::Function::New(context, invalidateCb, makeKeyData(key)).ToLocalChecked()).Check();

// Attach to import.meta
importMeta->CreateDataProperty(
Expand All @@ -158,15 +221,20 @@ void InitializeImportMetaHot(v8::Isolate* isolate,
}

// ─────────────────────────────────────────────────────────────
// Dev HTTP loader helpers
// HTTP loader helpers

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("://");
Expand All @@ -184,10 +252,10 @@ void InitializeImportMetaHot(v8::Isolate* isolate,
std::string originAndPath = (qPos == std::string::npos) ? noHash : noHash.substr(0, qPos);
std::string query = (qPos == std::string::npos) ? std::string() : noHash.substr(qPos + 1);

// Normalize bridge endpoints to keep a single realm across HMR updates:
// Normalize bridge endpoints to keep a single realm across reloads:
// - /ns/rt/<ver> -> /ns/rt
// - /ns/core/<ver> -> /ns/core
// Preserve query params (e.g. /ns/core?p=...) as part of module identity.
// Preserve query params (e.g. /ns/core?p=...), except for internal cache-busters (import, t, v), as part of module identity.
{
std::string pathOnly = originAndPath.substr(pathStart);
auto normalizeBridge = [&](const char* needle) {
Expand All @@ -213,9 +281,27 @@ void InitializeImportMetaHot(v8::Isolate* isolate,
normalizeBridge("/ns/core");
}

// IMPORTANT: This function is used as an HTTP module registry/cache key.
// For general-purpose HTTP module loading (public internet), the query string
// can be part of the module's identity (auth, content versioning, routing, etc).
// Therefore we only apply query normalization (sorting/dropping) for known
// NativeScript dev endpoints where `t`/`v`/`import` are purely cache busters.
{
std::string pathOnly = originAndPath.substr(pathStart);
const bool isDevEndpoint =
StartsWith(pathOnly, "/ns/") ||
StartsWith(pathOnly, "/node_modules/.vite/") ||
StartsWith(pathOnly, "/@id/") ||
StartsWith(pathOnly, "/@fs/");
if (!isDevEndpoint) {
// Preserve query as-is (fragment already removed).
return noHash;
}
}

if (query.empty()) return originAndPath;

// Keep all params except Vite's import marker; sort for stability.
// Keep all params except typical import markers or t/v cache busters; sort for stability.
std::vector<std::string> kept;
size_t start = 0;
while (start <= query.size()) {
Expand All @@ -224,7 +310,8 @@ void InitializeImportMetaHot(v8::Isolate* isolate,
if (!pair.empty()) {
size_t eq = pair.find('=');
std::string name = (eq == std::string::npos) ? pair : pair.substr(0, eq);
if (!(name == "import")) kept.push_back(pair);
// Drop import marker and common cache-busting stamps.
if (!(name == "import" || name == "t" || name == "v")) kept.push_back(pair);
}
if (amp == std::string::npos) break;
start = amp + 1;
Expand Down