Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
ee9b637
feat: HMR dev-sessions, ESM resolver hardening, dev-mode runtime globals
NathanWalker May 22, 2026
b93472b
fix: visionos build deprecation
NathanWalker Jun 14, 2026
0799678
feat: HMR robustness and additional tests
NathanWalker Jun 16, 2026
c842d46
test: unit test improvements
NathanWalker Jun 26, 2026
323d7c2
fix(worker): surface worker entry-script load errors to worker.onerror
NathanWalker Jun 27, 2026
6a1ca13
refactor(runtime): key module registries by v8::Isolate, not thread_l…
NathanWalker Jun 28, 2026
8dd8bc1
refactor(runtime): reduce surface to a mechanism-only dev-loader cont…
NathanWalker Jul 3, 2026
2e34b1e
feat(runtime): __NS_DEV__.seedModuleBodies for batch prewarm seeding …
NathanWalker Jul 4, 2026
b2f7bec
feat(runtime): harden the HTTP session loader
NathanWalker Jul 9, 2026
c83147a
fix(worker): buffer inbound messages until the entry script installs …
NathanWalker Jul 11, 2026
fbed492
fix(module): build the optional-module placeholder without string int…
NathanWalker Jul 11, 2026
8eec51a
fix(module): preserve embedded NUL bytes in dev-session module source
NathanWalker Jul 11, 2026
32a1954
fix(module): unify IsLikelyOptionalModule and exclude explicit file e…
NathanWalker Jul 21, 2026
6140fbb
fix(module): restore the optional-module placeholder call sites
NathanWalker Jul 29, 2026
a4ef202
docs(hmr): correct the V8 version pinned in the loader's constraint c…
NathanWalker Jul 29, 2026
216625f
test(runner): await promise-returning Jasmine specs
NathanWalker Jul 29, 2026
76bf1e2
fix(module): adopt thenables where JS hands the loader a promise
NathanWalker Jul 29, 2026
e24e505
test(esm): give the blob-URL module spec a Blob to work with
NathanWalker Jul 29, 2026
60b7e73
style(runtime): format this branch's runtime changes with clang-format
NathanWalker Jul 29, 2026
63ed317
refactor(module): strip debug-era scaffolding from the dynamic-import…
NathanWalker Jul 29, 2026
fd616e3
refactor(module): drop the native '@/' -> '/src/' alias expansion
NathanWalker Jul 29, 2026
ee4a8d3
refactor(module)!: reject missing bare import() specifiers instead of…
NathanWalker Jul 29, 2026
e4b9236
perf(runtime): move this branch's hash containers to robin_hood
NathanWalker Jul 29, 2026
ab8aa84
refactor(runtime)!: slim the __NS_DEV__ contract and drop ns-vendor://
NathanWalker Jul 30, 2026
b3ff737
chore: cleanup
NathanWalker Jul 30, 2026
c7c87af
cleanup
NathanWalker Jul 30, 2026
6ff01c8
feat(runtime): async module-graph loader replaces the prewarm/prefetc…
NathanWalker Jul 30, 2026
b394638
refactor: move embedded runtime JS to real .js files compiled in via …
edusperoni Jul 30, 2026
d2177d5
perf: stop re-evaluating enum JS snippets on every access
edusperoni Jul 30, 2026
8f027ff
refactor: compile builtins as function bodies with a fixed binding pa…
edusperoni Jul 30, 2026
ccf53ef
fix: harden builtin failure paths flagged in review
edusperoni Jul 30, 2026
b9b024e
refactor: export builtins via module.exports instead of top-level return
edusperoni Jul 30, 2026
2e03a6f
feat: add Node-style primordials to runtime builtins
edusperoni Jul 30, 2026
c8ad9bc
feat: budgeted console formatter (inspect builtin) replacing JSON-bas…
edusperoni Jul 30, 2026
52ef502
docs: drop stale smart-stringify references from primordials notes
edusperoni Jul 30, 2026
b045635
feat: expose inspect and format as the ns:util builtin module
edusperoni Jul 30, 2026
7e5ad28
docs: record the no-source-text-modules decision; js2c rejects non-.j…
edusperoni Jul 30, 2026
df985c3
Merge remote-tracking branch 'origin/feat/ns-util' into feat/hmr-dev-…
NathanWalker Jul 30, 2026
8bb7be1
feat(runtime): expose the dev-loader surface as the ns:runtime builti…
NathanWalker Jul 30, 2026
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
40 changes: 40 additions & 0 deletions .github/workflows/npm_release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ Thumbs.db
# VSCode
.vscode

# Generated by tools/js2c.mjs (Xcode "Generate RuntimeBuiltins" build phase)
NativeScript/runtime/generated/

# Other
node_modules/
package-lock.json
Expand Down
18 changes: 18 additions & 0 deletions NativeScript/NativeScript.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down
212 changes: 212 additions & 0 deletions NativeScript/runtime/BuiltinLoader.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
#include "BuiltinLoader.h"

#include <mutex>
#include <vector>

#include "Caches.h"
#include "Helpers.h"
#include "NsBuiltinModules.h"

using namespace v8;

namespace tns {

namespace {

// Process-wide bytecode cache shared across isolates (main + workers).
std::mutex builtinCacheMutex;
std::vector<uint8_t> builtinCache[static_cast<unsigned>(BuiltinId::kCount)];

// Every builtin is compiled as a function body receiving these fixed
// parameters, mirroring Node's module wrapper: a file exports through
// `module.exports`/`exports`, reaches sibling builtin modules through
// `require`, natives arrive as properties of the `binding` bag (Node's
// internalBinding idiom) and intrinsics as properties of `primordials`; each
// file destructures what it needs.
constexpr const char* kExportsParamName = "exports";
constexpr const char* kRequireParamName = "require";
constexpr const char* kModuleParamName = "module";
constexpr const char* kBindingParamName = "binding";
constexpr const char* kPrimordialsParamName = "primordials";
constexpr int kParamCount = 5;

// The `require` every builtin receives: builtin specifiers only, so a builtin
// can never reach application code or the filesystem.
void BuiltinRequireCallback(const FunctionCallbackInfo<Value>& info) {
Isolate* isolate = info.GetIsolate();
if (info.Length() < 1 || !info[0]->IsString()) {
isolate->ThrowException(Exception::TypeError(
tns::ToV8String(isolate, "require() expects a specifier string")));
return;
}

Local<Context> context = isolate->GetCurrentContext();
std::string specifier = tns::ToString(isolate, info[0].As<v8::String>());
Local<Object> exports;
if (NsBuiltinModules::GetExports(context, specifier).ToLocal(&exports)) {
info.GetReturnValue().Set(exports);
} else if (!NsBuiltinModules::IsRegistered(specifier)) {
isolate->ThrowException(Exception::Error(tns::ToV8String(
isolate, NsBuiltinModules::NotFoundMessage(specifier))));
}
}

MaybeLocal<v8::Function> GetBuiltinRequire(Local<Context> context) {
Isolate* isolate = v8::Isolate::GetCurrent();
std::shared_ptr<Caches> cache = Caches::Get(isolate);
if (cache->BuiltinRequire != nullptr) {
return cache->BuiltinRequire->Get(isolate);
}

Local<v8::Function> require;
if (!v8::Function::New(context, BuiltinRequireCallback, Local<Value>(), 1,
ConstructorBehavior::kThrow)
.ToLocal(&require)) {
return MaybeLocal<v8::Function>();
}
cache->BuiltinRequire =
std::make_unique<Persistent<v8::Function>>(isolate, require);
return require;
}

MaybeLocal<v8::Function> CompileBuiltin(Local<Context> context, BuiltinId id) {
Isolate* isolate = v8::Isolate::GetCurrent();
const BuiltinSource& builtin = GetBuiltinSource(id);
const unsigned index = static_cast<unsigned>(id);

// Copy the blob out so the shared slot can be refreshed concurrently while
// this compile still reads from the copy.
std::vector<uint8_t> blob;
{
std::lock_guard<std::mutex> lock(builtinCacheMutex);
blob = builtinCache[index];
}

ScriptOrigin origin(tns::ToV8String(isolate, builtin.name),
0, // line offset
0, // column offset
false, // shared_cross_origin
-1, // script_id
Local<Value>(),
false, // is_opaque
false, // is_wasm
false // is_module
);
Local<v8::String> sourceText = tns::ToV8String(
isolate, builtin.source, static_cast<int>(builtin.length));
Local<v8::String> params[] = {
tns::ToV8String(isolate, kExportsParamName),
tns::ToV8String(isolate, kRequireParamName),
tns::ToV8String(isolate, kModuleParamName),
tns::ToV8String(isolate, kBindingParamName),
tns::ToV8String(isolate, kPrimordialsParamName)};

Local<v8::Function> fn;
if (!blob.empty()) {
// The Source owns and deletes the CachedData object; BufferNotOwned keeps
// the underlying bytes (our copy) out of its hands.
auto* cachedData = new ScriptCompiler::CachedData(
blob.data(), static_cast<int>(blob.size()),
ScriptCompiler::CachedData::BufferNotOwned);
ScriptCompiler::Source source(sourceText, origin, cachedData);
if (ScriptCompiler::CompileFunction(context, &source, kParamCount, params,
0, nullptr,
ScriptCompiler::kConsumeCodeCache)
.ToLocal(&fn) &&
!cachedData->rejected) {
return fn;
}
// Rejected cache (e.g. produced under different flags): fall through and
// recompile eagerly so the refreshed blob covers inner functions again.
}

ScriptCompiler::Source source(sourceText, origin);
if (!ScriptCompiler::CompileFunction(context, &source, kParamCount, params, 0,
nullptr, ScriptCompiler::kEagerCompile)
.ToLocal(&fn)) {
return MaybeLocal<v8::Function>();
}

std::unique_ptr<ScriptCompiler::CachedData> produced(
ScriptCompiler::CreateCodeCacheForFunction(fn));
if (produced != nullptr && produced->data != nullptr &&
produced->length > 0) {
std::lock_guard<std::mutex> lock(builtinCacheMutex);
builtinCache[index].assign(produced->data,
produced->data + produced->length);
}

return fn;
}

MaybeLocal<Value> CallBuiltin(Local<Context> context, BuiltinId id,
Local<Value> binding, Local<Value> primordials) {
Isolate* isolate = v8::Isolate::GetCurrent();

Local<v8::Function> fn;
if (!CompileBuiltin(context, id).ToLocal(&fn)) {
return MaybeLocal<Value>();
}

Local<v8::Function> require;
if (!GetBuiltinRequire(context).ToLocal(&require)) {
return MaybeLocal<Value>();
}

Local<Object> exportsObj = Object::New(isolate);
Local<Object> moduleObj = Object::New(isolate);
Local<v8::String> exportsKey = tns::ToV8String(isolate, kExportsParamName);
if (!moduleObj->Set(context, exportsKey, exportsObj).FromMaybe(false)) {
return MaybeLocal<Value>();
}

Local<Value> args[] = {
exportsObj, require, moduleObj,
binding.IsEmpty() ? v8::Undefined(isolate).As<Value>() : binding,
primordials};
if (fn->Call(context, v8::Undefined(isolate), kParamCount, args).IsEmpty()) {
return MaybeLocal<Value>();
}

return moduleObj->Get(context, exportsKey);
}

// Snapshot of the intrinsics, taken the first time any builtin runs in this
// isolate — during runtime init, before user code can replace a global.
// Builtins compiled later in the isolate's life get the same pristine
// snapshot.
MaybeLocal<Object> GetPrimordials(Local<Context> context) {
Isolate* isolate = v8::Isolate::GetCurrent();
std::shared_ptr<Caches> cache = Caches::Get(isolate);
if (cache->Primordials != nullptr) {
return cache->Primordials->Get(isolate);
}

Local<Value> result;
if (!CallBuiltin(context, BuiltinId::kPrimordials, Local<Value>(),
v8::Undefined(isolate))
.ToLocal(&result) ||
!result->IsObject()) {
return MaybeLocal<Object>();
}

Local<Object> primordials = result.As<Object>();
cache->Primordials =
std::make_unique<Persistent<Object>>(isolate, primordials);
return primordials;
}

} // namespace

MaybeLocal<Value> BuiltinLoader::RunBuiltin(Local<Context> context,
BuiltinId id,
Local<Value> binding) {
Local<Object> primordials;
if (!GetPrimordials(context).ToLocal(&primordials)) {
return MaybeLocal<Value>();
}

return CallBuiltin(context, id, binding, primordials);
}

} // namespace tns
32 changes: 32 additions & 0 deletions NativeScript/runtime/BuiltinLoader.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#ifndef BuiltinLoader_h
#define BuiltinLoader_h

#include "Common.h"
#include "RuntimeBuiltins.h"

namespace tns {

class BuiltinLoader {
public:
// Compiles the builtin identified by id as a function body with the fixed
// parameters `exports`, `require`, `module`, `binding` (Node's module wrapper
// plus its internalBinding idiom) and `primordials`, calls it with the given
// bag of natives (or undefined when omitted) plus this isolate's frozen
// intrinsics snapshot, and returns the resulting `module.exports`. `require`
// reaches the builtin modules (NsBuiltinModules) and nothing else. The
// snapshot is
// produced by the kPrimordials builtin on first use and cached per isolate,
// so it is taken before any user code can replace a global. Scripts carry
// an "internal/<name>.js" origin so runtime
// frames are identifiable in stack traces. Compilation goes through a
// process-wide bytecode cache: the first run in the process compiles
// eagerly and populates the cache, later isolates (workers) consume it
// instead of re-parsing the source.
static v8::MaybeLocal<v8::Value> RunBuiltin(
v8::Local<v8::Context> context, BuiltinId id,
v8::Local<v8::Value> binding = v8::Local<v8::Value>());
};

} // namespace tns

#endif /* BuiltinLoader_h */
29 changes: 28 additions & 1 deletion NativeScript/runtime/Caches.h
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,13 @@ class Caches {
std::unique_ptr<v8::Persistent<v8::Function>>(nullptr);
std::unique_ptr<v8::Persistent<v8::Function>> WeakRefClearFunc =
std::unique_ptr<v8::Persistent<v8::Function>>(nullptr);
std::unique_ptr<v8::Persistent<v8::Function>> SmartJSONStringifyFunc =
// console formatter (internal/inspect.js), initialized by Console::Init.
std::unique_ptr<v8::Persistent<v8::Function>> InspectFunc =
std::unique_ptr<v8::Persistent<v8::Function>>(nullptr);
// ns:util's format, used by console.* for %-substitution.
std::unique_ptr<v8::Persistent<v8::Function>> FormatFunc =
std::unique_ptr<v8::Persistent<v8::Function>>(nullptr);
bool FormatFuncUnavailable = false;
std::unique_ptr<v8::Persistent<v8::Function>> InteropReferenceCtorFunc =
std::unique_ptr<v8::Persistent<v8::Function>>(nullptr);
std::unique_ptr<v8::Persistent<v8::Function>> PointerCtorFunc =
Expand All @@ -163,6 +168,28 @@ class Caches {
std::unique_ptr<v8::Persistent<v8::Function>> UnmanagedTypeCtorFunc =
std::unique_ptr<v8::Persistent<v8::Function>>(nullptr);

// `ns:`/`node:` builtin modules (NsBuiltinModules), keyed by specifier. Both
// are per isolate: a builtin module is a singleton per realm, so workers get
// their own exports objects and their own synthetic modules.
robin_hood::unordered_map<std::string,
std::unique_ptr<v8::Persistent<v8::Object>>>
BuiltinModuleExports;
robin_hood::unordered_map<std::string,
std::unique_ptr<v8::Persistent<v8::Module>>>
BuiltinModules;
// Specifiers currently being built, so a shim requiring back into the module
// that is loading it fails instead of recursing.
robin_hood::unordered_set<std::string> BuiltinModulesInProgress;
// The `require` handed to every builtin, resolving builtin specifiers only.
std::unique_ptr<v8::Persistent<v8::Function>> BuiltinRequire =
std::unique_ptr<v8::Persistent<v8::Function>>(nullptr);

// Frozen intrinsics snapshot returned by internal/primordials.js, passed to
// every builtin as its second fixed parameter (BuiltinLoader::RunBuiltin).
// Per isolate, so workers snapshot their own realm's intrinsics.
std::unique_ptr<v8::Persistent<v8::Object>> Primordials =
std::unique_ptr<v8::Persistent<v8::Object>>(nullptr);

// Internal EventTarget instance backing the global, returned by the generic
// event-primitives bootstrap IIFE (Events::Init). Holds the real listener
// store, so native layers dispatch through it without going through
Expand Down
Loading