Skip to content

Commit 5a8e91a

Browse files
committed
feat(runtime): expose the dev-loader surface as the ns:runtime builtin module
The five dev primitives — configureRuntime, invalidateModules, getLoadedModuleUrls, setDevBootComplete, terminateAllWorkers — resolve through the NsBuiltinModules registry: require("ns:runtime"), static import, and import() all yield the same frozen per-realm module, materialized lazily from BuildNsRuntimeBinding (HMRSupport.mm). The dev surface defines no globals. Membership is realm- and build-scoped: terminateAllWorkers exists only in the main realm (a worker must not take down its peers) and canonicalizeHttpUrlKey only in debug builds; missing members are absent, never present-but-throwing, so feature checks work. Builtin specifiers resolve only through the registry — an import-map entry can never shadow them onto HTTP.
1 parent cfb9e71 commit 5a8e91a

9 files changed

Lines changed: 193 additions & 143 deletions

File tree

NativeScript/runtime/HMRSupport.h

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,8 @@ void MarkUrlsForCacheBust(const std::vector<std::string>& urls);
9696
// Flip the dev-boot-complete signal: sets the JS-visible
9797
// `__NS_HMR_BOOT_COMPLETE__` global and the native atomic that gates the
9898
// cold-boot-only behaviors (JS-thread runloop pump between synchronous
99-
// fetches). Exposed to JS as
100-
// `__NS_DEV__.setDevBootComplete(value?: boolean)`.
99+
// fetches). Exposed to JS as ns:runtime
100+
// `setDevBootComplete(value?: boolean)`.
101101
void SetDevBootComplete(v8::Isolate* isolate, v8::Local<v8::Context> context,
102102
bool value);
103103

@@ -108,30 +108,27 @@ void SetDevBootComplete(v8::Isolate* isolate, v8::Local<v8::Context> context,
108108
// still uses).
109109
void CleanupHMRGlobals();
110110

111-
// Mirror a globally-installed value onto `globalThis.<name>` so
112-
// `globalThis.<name>` lookups resolve when the runtime installs the
113-
// canonical value on the realm's global object.
114-
void MirrorGlobalOnGlobalThis(v8::Isolate* isolate,
115-
v8::Local<v8::Context> context, const char* name);
116-
117111
// ─────────────────────────────────────────────────────────────
118-
// Dev host namespace installer
112+
// The `ns:runtime` builtin binding
119113
//
120-
// Installs the single `__NS_DEV__` namespace object that carries every
121-
// JS-callable dev primitive that any tooling can depend on.
122-
// Idempotent per realm; safe to call from any place that has a fresh
123-
// context + isolate scope. Installed on the realm's global object AND
124-
// mirrored on globalThis.
114+
// Populates the native half of the `ns:runtime` builtin module — the one
115+
// namespace carrying every JS-callable dev primitive that any tooling can
116+
// depend on. Called from NsBuiltinModules::BuildBinding the first time a
117+
// realm resolves `ns:runtime` (via require, static import, or import());
118+
// ns-runtime.js shapes and freezes the exports.
125119
//
126-
// `__NS_DEV__` members:
120+
// `ns:runtime` members:
127121
// - configureRuntime(config) (import map + volatile patterns +
128122
// canonicalization vocabulary)
129123
// - invalidateModules(urls) (registry + cache eviction)
130124
// - getLoadedModuleUrls() (registry introspection)
131125
// - setDevBootComplete(value?) (boot-complete signal)
132-
// - terminateAllWorkers() (main isolate only; see Worker.h)
126+
// - terminateAllWorkers() (main realm only; see Worker.h)
133127
// - canonicalizeHttpUrlKey(url) (debug builds only; test diagnostic)
134-
void InitializeHmrDevGlobals(v8::Isolate* isolate,
135-
v8::Local<v8::Context> context, bool isWorker);
128+
//
129+
// Returns false (with an exception pending or a failed Set) when the
130+
// binding could not be populated.
131+
bool BuildNsRuntimeBinding(v8::Local<v8::Context> context,
132+
v8::Local<v8::Object> binding);
136133

137134
} // namespace tns

NativeScript/runtime/HMRSupport.mm

Lines changed: 54 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
#include <mutex>
1010
#include <string>
1111
#include <vector>
12+
#include "Caches.h"
1213
#include "Helpers.h"
1314
#include "ModuleInternalCallbacks.h"
1415
#include "Runtime.h"
@@ -25,22 +26,6 @@ static inline bool StartsWith(const std::string& s, const char* prefix) {
2526
return s.size() >= n && s.compare(0, n, prefix) == 0;
2627
}
2728

28-
void MirrorGlobalOnGlobalThis(v8::Isolate* isolate, v8::Local<v8::Context> context,
29-
const char* name) {
30-
std::string src = "if (typeof globalThis !== 'undefined' && typeof globalThis." +
31-
std::string(name) +
32-
" === 'undefined') {"
33-
" Object.defineProperty(globalThis, '" +
34-
std::string(name) + "', { value: this." + std::string(name) +
35-
", writable: true, configurable: true, enumerable: false });"
36-
"}";
37-
38-
v8::Local<v8::Script> script;
39-
if (v8::Script::Compile(context, tns::ToV8String(isolate, src.c_str())).ToLocal(&script)) {
40-
script->Run(context).FromMaybe(v8::Local<v8::Value>());
41-
}
42-
}
43-
4429
static void SetBooleanGlobal(v8::Isolate* isolate, v8::Local<v8::Context> context, const char* key,
4530
bool value) {
4631
context->Global()
@@ -54,8 +39,8 @@ static void SetBooleanGlobal(v8::Isolate* isolate, v8::Local<v8::Context> contex
5439
// Native-side mirror of `__NS_HMR_BOOT_COMPLETE__`. Read by the
5540
// runloop pump in `MaybePumpJSThreadDuringBoot` so its gate is a
5641
// single relaxed atomic load on the HMR-time hot path. The JS dev
57-
// client flips this via the
58-
// `__NS_DEV__.setDevBootComplete(bool)` global once the real app root view
42+
// client flips this via ns:runtime
43+
// `setDevBootComplete(bool)` once the real app root view
5944
// commits; boot orchestration itself is entirely userland.
6045
static std::atomic<bool> g_devSessionBootComplete{false};
6146

@@ -83,7 +68,7 @@ void SetDevBootComplete(v8::Isolate* isolate, v8::Local<v8::Context> context, bo
8368
// queries may be normalized, and which paths must keep their query verbatim
8469
// because the query IS the identity — is server/framework policy, supplied
8570
// by the dev client via
86-
// `__NS_DEV__.configureRuntime({ canonicalization: {...} })`.
71+
// ns:runtime `configureRuntime({ canonicalization: {...} })`.
8772
//
8873
// Write-before-read contract: the client configures this once, before the
8974
// first import wave (session-bootstrap order), so plain statics are
@@ -94,10 +79,6 @@ void SetDevBootComplete(v8::Isolate* isolate, v8::Local<v8::Context> context, bo
9479
//
9580
// When unconfigured, a built-in vocabulary matching current
9681
// `@nativescript/vite` conventions applies.
97-
// TODO(feat/hmr-dev-sessions follow-up): delete the built-in vocabulary once
98-
// the paired `@nativescript/vite` release that sends `canonicalization` has
99-
// been qualified — the runtime should carry zero server/framework URL
100-
// strings.
10182
struct CanonicalizationConfig {
10283
std::vector<std::string> stripParams; // query param names to drop
10384
std::vector<std::string> devPathPrefixes; // path StartsWith → normalize query
@@ -110,7 +91,7 @@ static void SetCanonicalizationConfig(CanonicalizationConfig config) {
11091
g_canonConfig = std::move(config);
11192
g_canonConfigured = true;
11293
if (IsScriptLoadingLogEnabled()) {
113-
Log(@"[__NS_DEV__.configureRuntime] canonicalization set (strip=%lu devPrefixes=%lu "
94+
Log(@"[ns:runtime configureRuntime] canonicalization set (strip=%lu devPrefixes=%lu "
11495
@"preserve=%lu)",
11596
(unsigned long)g_canonConfig.stripParams.size(),
11697
(unsigned long)g_canonConfig.devPathPrefixes.size(),
@@ -161,7 +142,7 @@ static void ResetCanonicalizationConfig() {
161142
//
162143
// The dev server serves every module under ONE canonical URL — module
163144
// identity IS the URL string. Freshness after an HMR edit is handled by
164-
// `__NS_DEV__.invalidateModules` (registry evict) plus the
145+
// ns:runtime `invalidateModules` (registry evict) plus the
165146
// eviction-driven fetch nonce in `PerformHttpFetchOnceSync`, never by URL
166147
// variation. There is deliberately no path-tag vocabulary to collapse here.
167148
//
@@ -192,7 +173,8 @@ static void ResetCanonicalizationConfig() {
192173
return noHash;
193174
}
194175
} else {
195-
// Built-in fallback vocabulary — see the deletion TODO above.
176+
// Unconfigured: built-in vocabulary matching `@nativescript/vite`
177+
// conventions.
196178
if (pathOnly.find("/@ng/component") != std::string::npos) {
197179
return noHash;
198180
}
@@ -300,12 +282,10 @@ static void ClearAllCacheBustMarks() {
300282
// which fetches the transitive closure concurrently off the JS
301283
// thread before instantiation begins.
302284
//
303-
// There is deliberately NO body prewarm cache and NO JS-driven prefetch
304-
// API here. Measurement (see docs/knowledge/hmr-simplification-pass.md)
305-
// showed the async discovery walk beats server-computed
306-
// closure/archive seeding on real apps — concurrent fetches overlap
307-
// with on-device compile, while seeding serializes a full server-side
308-
// transform pass before the entry can start.
285+
// These two are the loader's complete fetch surface: the async graph walk
286+
// owns all body fetching. Concurrent per-module fetches overlap with
287+
// on-device compile, which measured fastest on real apps
288+
// (HMR_API_NECESSITY_REVIEW.md §8.3).
309289

310290
// Forward declarations — these helpers are defined below their first use,
311291
// matching the existing convention in this file.
@@ -754,7 +734,7 @@ void FetchModuleBodyAsync(const std::string& url,
754734
// background threads, so they never pump someone else's runloop.
755735
// - `IsDevSessionBootComplete()` short-circuits once the dev client
756736
// has committed its first stable view (it calls
757-
// `__NS_DEV__.setDevBootComplete(true)`) — no placeholder to repaint, and
737+
// ns:runtime `setDevBootComplete(true)`) — no placeholder to repaint, and
758738
// HMR-time fetches must not pay the pump cost.
759739
// - The runloop identity check survives any future change that
760740
// decouples the runtime's captured runloop from the current thread.
@@ -814,18 +794,20 @@ void CleanupHMRGlobals() {
814794
}
815795

816796
// ─────────────────────────────────────────────────────────────
817-
// Dev-loader JS-callable globals
797+
// The `ns:runtime` dev surface
818798
//
819799
// The runtime's dev surface is deliberately small: it exposes
820800
// *mechanism* only (resolution config, registry eviction, registry
821801
// introspection, boot-complete signal). All HMR *policy* — boot
822802
// orchestration, `import.meta.hot`, full reload, CSS apply, WebSocket
823803
// protocol — lives in the JS dev client (`@nativescript/vite`).
804+
// The surface is reachable exclusively through the `ns:runtime` builtin
805+
// module (require / static import / import()); there is no global.
824806

825807
namespace {
826808

827809
// Sets the function name on the v8 Function for nicer stack traces and
828-
// attaches it as a method of the `__NS_DEV__` namespace object.
810+
// attaches it as a member of the `ns:runtime` binding object.
829811
void InstallDevFunction(v8::Isolate* isolate, v8::Local<v8::Context> context,
830812
v8::Local<v8::Object> target, const char* name,
831813
v8::FunctionCallback callback) {
@@ -843,7 +825,7 @@ void ConfigureDevRuntimeCallback(const v8::FunctionCallbackInfo<v8::Value>& info
843825

844826
if (info.Length() < 1 || !info[0]->IsObject()) {
845827
if (logScriptLoading) {
846-
Log(@"[__NS_DEV__.configureRuntime] expected config object argument");
828+
Log(@"[ns:runtime configureRuntime] expected config object argument");
847829
}
848830
return;
849831
}
@@ -877,7 +859,7 @@ void ConfigureDevRuntimeCallback(const v8::FunctionCallbackInfo<v8::Value>& info
877859
if (!jsonStr.empty()) {
878860
SetImportMap(jsonStr);
879861
if (logScriptLoading) {
880-
Log(@"[__NS_DEV__.configureRuntime] import map set (%zu bytes)", jsonStr.size());
862+
Log(@"[ns:runtime configureRuntime] import map set (%zu bytes)", jsonStr.size());
881863
}
882864
}
883865
}
@@ -907,7 +889,7 @@ void ConfigureDevRuntimeCallback(const v8::FunctionCallbackInfo<v8::Value>& info
907889
if (readStringArray(config, "volatilePatterns", patterns) && !patterns.empty()) {
908890
SetVolatilePatterns(patterns);
909891
if (logScriptLoading) {
910-
Log(@"[__NS_DEV__.configureRuntime] %zu volatile patterns set", patterns.size());
892+
Log(@"[ns:runtime configureRuntime] %zu volatile patterns set", patterns.size());
911893
}
912894
}
913895
}
@@ -936,7 +918,7 @@ void InvalidateModulesCallback(const v8::FunctionCallbackInfo<v8::Value>& info)
936918
v8::Local<v8::Context> ctx = isolate->GetCurrentContext();
937919

938920
if (info.Length() < 1 || !info[0]->IsArray()) {
939-
Log(@"[__NS_DEV__.invalidateModules] expected array of URL strings");
921+
Log(@"[ns:runtime invalidateModules] expected array of URL strings");
940922
return;
941923
}
942924

@@ -992,7 +974,7 @@ void GetLoadedModuleUrlsCallback(const v8::FunctionCallbackInfo<v8::Value>& info
992974
info.GetReturnValue().Set(result);
993975
}
994976

995-
// `__NS_DEV__.setDevBootComplete(value?: boolean)` — the JS dev client calls
977+
// ns:runtime `setDevBootComplete(value?: boolean)` — the JS dev client calls
996978
// this (with `true`, or no argument) once the real app root view has
997979
// committed. It flips both the JS-visible `__NS_HMR_BOOT_COMPLETE__`
998980
// global and the native atomic that disarms the cold-boot runloop pump.
@@ -1013,48 +995,48 @@ void SetDevBootCompleteCallback(const v8::FunctionCallbackInfo<v8::Value>& info)
1013995

1014996
} // namespace
1015997

1016-
void InitializeHmrDevGlobals(v8::Isolate* isolate, v8::Local<v8::Context> context, bool isWorker) {
1017-
// The dev host API lives here: `__NS_DEV__`.
1018-
v8::Local<v8::Object> dev = v8::Object::New(isolate);
998+
bool BuildNsRuntimeBinding(v8::Local<v8::Context> context, v8::Local<v8::Object> binding) {
999+
v8::Isolate* isolate = v8::Isolate::GetCurrent();
10191000

1020-
InstallDevFunction(isolate, context, dev, "configureRuntime", ConfigureDevRuntimeCallback);
1021-
InstallDevFunction(isolate, context, dev, "invalidateModules", InvalidateModulesCallback);
1022-
InstallDevFunction(isolate, context, dev, "getLoadedModuleUrls", GetLoadedModuleUrlsCallback);
1023-
InstallDevFunction(isolate, context, dev, "setDevBootComplete", SetDevBootCompleteCallback);
1001+
InstallDevFunction(isolate, context, binding, "configureRuntime", ConfigureDevRuntimeCallback);
1002+
InstallDevFunction(isolate, context, binding, "invalidateModules", InvalidateModulesCallback);
1003+
InstallDevFunction(isolate, context, binding, "getLoadedModuleUrls", GetLoadedModuleUrlsCallback);
1004+
InstallDevFunction(isolate, context, binding, "setDevBootComplete", SetDevBootCompleteCallback);
10241005

1025-
// Main-isolate only: terminating workers from inside a worker would let
1026-
// a stuck worker take down its peers (see Worker.h).
1027-
if (!isWorker) {
1028-
InstallDevFunction(isolate, context, dev, "terminateAllWorkers",
1006+
// Main-realm only: terminating workers from inside a worker would let
1007+
// a stuck worker take down its peers (see Worker.h). A worker realm's
1008+
// `ns:runtime` simply has no such member, so feature checks work.
1009+
if (!Caches::Get(isolate)->isWorker) {
1010+
InstallDevFunction(isolate, context, binding, "terminateAllWorkers",
10291011
Worker::TerminateAllWorkersCallback);
10301012
}
10311013

10321014
if (RuntimeConfig.IsDebug) {
1033-
try {
1034-
// Debug-only diagnostic: expose the HTTP canonical-key function to JS so
1035-
// the test harness can pin its identity behavior across cache-busters
1036-
// and dev-endpoint query normalization.
1037-
auto canonicalizeCb = [](const v8::FunctionCallbackInfo<v8::Value>& info) {
1038-
v8::Isolate* iso = info.GetIsolate();
1039-
if (info.Length() < 1 || !info[0]->IsString()) {
1040-
info.GetReturnValue().SetEmptyString();
1041-
return;
1042-
}
1043-
v8::String::Utf8Value u(iso, info[0]);
1044-
std::string key = CanonicalizeHttpUrlKey(*u ? std::string(*u) : std::string());
1045-
info.GetReturnValue().Set(tns::ToV8String(iso, key.c_str()));
1046-
};
1047-
v8::Local<v8::Function> fn = v8::Function::New(context, canonicalizeCb).ToLocalChecked();
1015+
// Debug-only diagnostic: expose the HTTP canonical-key function to JS so
1016+
// the test harness can pin its identity behavior across cache-busters
1017+
// and dev-endpoint query normalization.
1018+
auto canonicalizeCb = [](const v8::FunctionCallbackInfo<v8::Value>& info) {
1019+
v8::Isolate* iso = info.GetIsolate();
1020+
if (info.Length() < 1 || !info[0]->IsString()) {
1021+
info.GetReturnValue().SetEmptyString();
1022+
return;
1023+
}
1024+
v8::String::Utf8Value u(iso, info[0]);
1025+
std::string key = CanonicalizeHttpUrlKey(*u ? std::string(*u) : std::string());
1026+
info.GetReturnValue().Set(tns::ToV8String(iso, key.c_str()));
1027+
};
1028+
v8::Local<v8::Function> fn;
1029+
if (v8::Function::New(context, canonicalizeCb).ToLocal(&fn)) {
10481030
fn->SetName(tns::ToV8String(isolate, "canonicalizeHttpUrlKey"));
1049-
dev->CreateDataProperty(context, tns::ToV8String(isolate, "canonicalizeHttpUrlKey"), fn)
1050-
.Check();
1051-
} catch (...) {
1052-
// Don't crash if debug-diagnostic setup fails
1031+
if (!binding
1032+
->CreateDataProperty(context, tns::ToV8String(isolate, "canonicalizeHttpUrlKey"), fn)
1033+
.FromMaybe(false)) {
1034+
return false;
1035+
}
10531036
}
10541037
}
10551038

1056-
context->Global()->Set(context, tns::ToV8String(isolate, "__NS_DEV__"), dev).FromMaybe(false);
1057-
MirrorGlobalOnGlobalThis(isolate, context, "__NS_DEV__");
1039+
return true;
10581040
}
10591041

10601042
} // namespace tns

NativeScript/runtime/ModuleInternalCallbacks.mm

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -376,15 +376,16 @@ void DestroyModuleStateForIsolate(v8::Isolate* isolate) {
376376
}
377377

378378
// ────────────────────────────────────────────────────────────────────────────
379-
// Import map: bare specifier → resolved URL (populated by __NS_DEV__.configureRuntime)
379+
// Import map: bare specifier → resolved URL (populated by ns:runtime configureRuntime)
380380
// Instead of rewriting import statements in source code on the Vite side, the runtime
381381
// resolves bare specifiers through this map to HTTP module URLs. Source code
382382
// is served as Vite transformed it.
383383
static robin_hood::unordered_map<std::string, std::string> g_importMap;
384384

385385
// Volatile URL patterns: URLs matching these substrings are always re-fetched
386-
// (cache is evicted before loading). Configured by Vite at boot instead of
387-
// being hardcoded. Replaces hardcoded /@ns/sfc/ and __webpack_* checks.
386+
// (cache is evicted before loading). Configured by Vite at boot — the
387+
// vocabulary is server/framework policy, so the runtime carries no
388+
// framework-specific URL strings here.
388389
static std::vector<std::string> g_volatilePatterns;
389390

390391
static bool ShouldTraceRegistryKey(const std::string& rawKey, const std::string& registryKey) {
@@ -2045,7 +2046,7 @@ static bool IsDocumentsPath(const std::string& path) {
20452046
normalizedSpec; // use normalized spec for the rest of the resolution logic
20462047

20472048
// Import map resolution
2048-
// If the import map is populated (set by __NS_DEV__.configureRuntime), check it
2049+
// If the import map is populated (set by ns:runtime configureRuntime), check it
20492050
// before any other resolution. This is the highest-leverage change from
20502051
// the HMR architecture review: bare specifiers resolve through the map
20512052
// to either vendor URLs or HTTP module URLs, eliminating the need for
@@ -3515,7 +3516,7 @@ static void FinishHttpDynamicImport(v8::Isolate* isolate, v8::Local<v8::Context>
35153516
// Volatile pattern check: if the URL matches any configured volatile
35163517
// pattern, evict the cached module so we always re-fetch. The pattern
35173518
// list is policy and is supplied exclusively by the dev client via
3518-
// `__NS_DEV__.configureRuntime({ volatilePatterns })` — the runtime
3519+
// ns:runtime `configureRuntime({ volatilePatterns })` — the runtime
35193520
// carries no framework or server URL vocabulary of its own. (Framework
35203521
// strategies ship their own endpoints, e.g. Angular's `/@ng/component`
35213522
// whose per-save `t` param would otherwise accumulate one stale

NativeScript/runtime/NsBuiltinModules.cpp

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
#include "BuiltinLoader.h"
66
#include "Caches.h"
77
#include "Console.h"
8+
#include "HMRSupport.h"
89
#include "Helpers.h"
910

1011
using namespace v8;
@@ -26,6 +27,7 @@ struct Registration {
2627
// adapts, so the two module objects stay distinct and the standard module
2728
// never carries compatibility code.
2829
constexpr Registration kRegistry[] = {
30+
{"ns:runtime", BuiltinId::kNsRuntime},
2931
{"ns:util", BuiltinId::kNsUtil},
3032
{"node:util", BuiltinId::kNodeUtil},
3133
};
@@ -48,6 +50,15 @@ MaybeLocal<Object> BuildBinding(Local<Context> context, BuiltinId builtin) {
4850
Local<Object> binding = Object::New(isolate);
4951

5052
switch (builtin) {
53+
case BuiltinId::kNsRuntime: {
54+
// The dev-loader control surface (HMRSupport.mm). The binding builder
55+
// decides realm/build-dependent membership; ns-runtime.js only shapes
56+
// and freezes whatever arrives.
57+
if (!BuildNsRuntimeBinding(context, binding)) {
58+
return MaybeLocal<Object>();
59+
}
60+
break;
61+
}
5162
case BuiltinId::kNsUtil: {
5263
// The console formatter is built once per realm; ns:util re-exports that
5364
// instance instead of creating a second one.

0 commit comments

Comments
 (0)