Skip to content

Commit bce9ece

Browse files
committed
feat: add Node-style primordials to runtime builtins
The runtime's builtins install globals and leave closures behind that keep running for the lifetime of the app: event dispatch, the Promise proxy traps, console's smart-stringify, __extends. Those closures reached for intrinsics (Array.prototype.slice, JSON.stringify, Object.create, ...) through the live globals, so app code replacing one could break or observe runtime internals. primordials.js captures the intrinsics the other builtins need into a frozen, null-prototype namespace and BuiltinLoader passes it to every builtin as a second fixed parameter next to `binding`. It is built lazily on the first RunBuiltin of an isolate — during runtime init, before user code — and cached in Caches, so workers snapshot their own realm and late-compiling builtins still see pristine intrinsics. Instance methods are uncurried Node-style, `uncurryThis(fn)` being Function.prototype.bind.bind(Function.prototype.call): ArrayPrototypeSlice(list, 1) rather than list.slice(1). Benchmarked under jitless (which is how the runtime always runs on device) uncurried calls cost +5-12% per op over a raw method call but beat the captured-and-.call() alternative, so they are used uniformly. ESLint gains no-restricted-properties for the captured statics and no-restricted-globals for the captured constructors, each pointing at the replacement; uncurried instance-method use stays a review rule since the receiver cannot be matched.
1 parent a56b0d4 commit bce9ece

17 files changed

Lines changed: 559 additions & 80 deletions

NativeScript/runtime/BuiltinLoader.cpp

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
#include <mutex>
44
#include <vector>
55

6+
#include "Caches.h"
67
#include "Helpers.h"
78

89
using namespace v8;
@@ -15,10 +16,13 @@ namespace {
1516
std::mutex builtinCacheMutex;
1617
std::vector<uint8_t> builtinCache[static_cast<unsigned>(BuiltinId::kCount)];
1718

18-
// Every builtin is compiled as a function body receiving this single, fixed
19-
// parameter (Node's internalBinding idiom): natives arrive as properties of
20-
// one bag object and each file destructures what it needs.
19+
// Every builtin is compiled as a function body receiving these two fixed
20+
// parameters: natives arrive as properties of the `binding` bag (Node's
21+
// internalBinding idiom) and intrinsics as properties of `primordials`; each
22+
// file destructures what it needs.
2123
constexpr const char* kBindingParamName = "binding";
24+
constexpr const char* kPrimordialsParamName = "primordials";
25+
constexpr int kParamCount = 2;
2226

2327
MaybeLocal<v8::Function> CompileBuiltin(Local<Context> context, BuiltinId id) {
2428
Isolate* isolate = v8::Isolate::GetCurrent();
@@ -45,7 +49,9 @@ MaybeLocal<v8::Function> CompileBuiltin(Local<Context> context, BuiltinId id) {
4549
);
4650
Local<v8::String> sourceText = tns::ToV8String(
4751
isolate, builtin.source, static_cast<int>(builtin.length));
48-
Local<v8::String> params[] = {tns::ToV8String(isolate, kBindingParamName)};
52+
Local<v8::String> params[] = {
53+
tns::ToV8String(isolate, kBindingParamName),
54+
tns::ToV8String(isolate, kPrimordialsParamName)};
4955

5056
Local<v8::Function> fn;
5157
if (!blob.empty()) {
@@ -55,7 +61,8 @@ MaybeLocal<v8::Function> CompileBuiltin(Local<Context> context, BuiltinId id) {
5561
blob.data(), static_cast<int>(blob.size()),
5662
ScriptCompiler::CachedData::BufferNotOwned);
5763
ScriptCompiler::Source source(sourceText, origin, cachedData);
58-
if (ScriptCompiler::CompileFunction(context, &source, 1, params, 0, nullptr,
64+
if (ScriptCompiler::CompileFunction(context, &source, kParamCount, params,
65+
0, nullptr,
5966
ScriptCompiler::kConsumeCodeCache)
6067
.ToLocal(&fn) &&
6168
!cachedData->rejected) {
@@ -66,8 +73,8 @@ MaybeLocal<v8::Function> CompileBuiltin(Local<Context> context, BuiltinId id) {
6673
}
6774

6875
ScriptCompiler::Source source(sourceText, origin);
69-
if (!ScriptCompiler::CompileFunction(context, &source, 1, params, 0, nullptr,
70-
ScriptCompiler::kEagerCompile)
76+
if (!ScriptCompiler::CompileFunction(context, &source, kParamCount, params, 0,
77+
nullptr, ScriptCompiler::kEagerCompile)
7178
.ToLocal(&fn)) {
7279
return MaybeLocal<v8::Function>();
7380
}
@@ -84,21 +91,57 @@ MaybeLocal<v8::Function> CompileBuiltin(Local<Context> context, BuiltinId id) {
8491
return fn;
8592
}
8693

94+
MaybeLocal<Value> CallBuiltin(Local<Context> context, BuiltinId id,
95+
Local<Value> binding, Local<Value> primordials) {
96+
Isolate* isolate = v8::Isolate::GetCurrent();
97+
98+
Local<v8::Function> fn;
99+
if (!CompileBuiltin(context, id).ToLocal(&fn)) {
100+
return MaybeLocal<Value>();
101+
}
102+
103+
Local<Value> args[] = {
104+
binding.IsEmpty() ? v8::Undefined(isolate).As<Value>() : binding,
105+
primordials};
106+
return fn->Call(context, v8::Undefined(isolate), kParamCount, args);
107+
}
108+
109+
// Snapshot of the intrinsics, taken the first time any builtin runs in this
110+
// isolate — during runtime init, before user code can replace a global. Later
111+
// builtins (smart-stringify compiles lazily, on the first object logged) get
112+
// the same pristine snapshot.
113+
MaybeLocal<Object> GetPrimordials(Local<Context> context) {
114+
Isolate* isolate = v8::Isolate::GetCurrent();
115+
std::shared_ptr<Caches> cache = Caches::Get(isolate);
116+
if (cache->Primordials != nullptr) {
117+
return cache->Primordials->Get(isolate);
118+
}
119+
120+
Local<Value> result;
121+
if (!CallBuiltin(context, BuiltinId::kPrimordials, Local<Value>(),
122+
v8::Undefined(isolate))
123+
.ToLocal(&result) ||
124+
!result->IsObject()) {
125+
return MaybeLocal<Object>();
126+
}
127+
128+
Local<Object> primordials = result.As<Object>();
129+
cache->Primordials =
130+
std::make_unique<Persistent<Object>>(isolate, primordials);
131+
return primordials;
132+
}
133+
87134
} // namespace
88135

89136
MaybeLocal<Value> BuiltinLoader::RunBuiltin(Local<Context> context,
90137
BuiltinId id,
91138
Local<Value> binding) {
92-
Isolate* isolate = v8::Isolate::GetCurrent();
93-
94-
Local<v8::Function> fn;
95-
if (!CompileBuiltin(context, id).ToLocal(&fn)) {
139+
Local<Object> primordials;
140+
if (!GetPrimordials(context).ToLocal(&primordials)) {
96141
return MaybeLocal<Value>();
97142
}
98143

99-
Local<Value> args[] = {binding.IsEmpty() ? v8::Undefined(isolate).As<Value>()
100-
: binding};
101-
return fn->Call(context, v8::Undefined(isolate), 1, args);
144+
return CallBuiltin(context, id, binding, primordials);
102145
}
103146

104147
} // namespace tns

NativeScript/runtime/BuiltinLoader.h

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@ namespace tns {
88

99
class BuiltinLoader {
1010
public:
11-
// Compiles the builtin identified by id as a function body with the single
12-
// fixed parameter `binding` (Node's internalBinding idiom), calls it with
13-
// the given bag of natives (or undefined when omitted), and returns its
14-
// return value. Scripts carry an "internal/<name>.js" origin so runtime
11+
// Compiles the builtin identified by id as a function body with the two
12+
// fixed parameters `binding` (Node's internalBinding idiom) and
13+
// `primordials`, calls it with the given bag of natives (or undefined when
14+
// omitted) plus this isolate's frozen intrinsics snapshot, and returns its
15+
// return value. The snapshot is produced by the kPrimordials builtin on
16+
// first use and cached per isolate, so it is taken before any user code can
17+
// replace a global. Scripts carry an "internal/<name>.js" origin so runtime
1518
// frames are identifiable in stack traces. Compilation goes through a
1619
// process-wide bytecode cache: the first run in the process compiles
1720
// eagerly and populates the cache, later isolates (workers) consume it

NativeScript/runtime/Caches.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,12 @@ class Caches {
163163
std::unique_ptr<v8::Persistent<v8::Function>> UnmanagedTypeCtorFunc =
164164
std::unique_ptr<v8::Persistent<v8::Function>>(nullptr);
165165

166+
// Frozen intrinsics snapshot returned by internal/primordials.js, passed to
167+
// every builtin as its second fixed parameter (BuiltinLoader::RunBuiltin).
168+
// Per isolate, so workers snapshot their own realm's intrinsics.
169+
std::unique_ptr<v8::Persistent<v8::Object>> Primordials =
170+
std::unique_ptr<v8::Persistent<v8::Object>>(nullptr);
171+
166172
// Internal EventTarget instance backing the global, returned by the generic
167173
// event-primitives bootstrap IIFE (Events::Init). Holds the real listener
168174
// store, so native layers dispatch through it without going through

NativeScript/runtime/js/README.md

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,32 +9,66 @@ at runtime `BuiltinLoader::RunBuiltin` compiles and executes them with an
99
## Contract (Node's internalBinding idiom)
1010

1111
Every file is compiled as a **function body** via `v8::ScriptCompiler::CompileFunction`
12-
with one fixed parameter:
12+
with two fixed parameters:
1313

1414
```js
1515
const { someNative, anotherNative } = binding;
16+
const { ArrayPrototypeSlice, ObjectCreate } = primordials;
1617
```
1718

1819
- `binding` is a plain object of natives built by the C++ call site; a file
1920
that needs nothing from C++ simply doesn't mention it.
21+
- `primordials` is the frozen intrinsics snapshot built by `primordials.js`
22+
(see below), the same object for every builtin in an isolate.
2023
- Because the file is a function body, **top-level `return` is legal** — a
2124
builtin's return value is what `RunBuiltin` hands back to C++ (used to
2225
return factory functions and init results).
2326
- Strict mode is per-file: start the file with `"use strict";` to opt in.
24-
- Destructure `binding` once, at the top of the file, so the file's native
25-
dependencies are visible and greppable.
27+
- Destructure `binding` and `primordials` once, at the top of the file, so the
28+
file's dependencies are visible and greppable.
2629

2730
## Rules
2831

2932
- Run at isolate init, before any user code: capture any global you rely on
3033
(e.g. `globalThis.Event`) eagerly so later monkey-patching can't break you.
31-
(Planned: a frozen `primordials` namespace as a second fixed parameter,
32-
Node-style, to make this systematic for closures that outlive init.)
34+
For intrinsics that is what `primordials` is; for everything else
35+
(`URLSearchParams`, …) capture it into a file-level `const`.
3336
- No `import`/`export` — these are classic function bodies, not modules.
3437
- ESLint (`eslint.config.mjs` at the repo root, run by lint-staged) declares
35-
`binding` and the reachable native globals; `no-undef` is the typo net.
36-
If a builtin starts using a new native global, add it there.
38+
`binding`, `primordials` and the reachable native globals; `no-undef` is the
39+
typo net. If a builtin starts using a new native global, add it there.
40+
`no-restricted-properties` fails the lint on direct use of the captured
41+
statics (`JSON.stringify`, `Object.defineProperty`, …). Uncurried instance
42+
methods can't be matched that way, so `list.slice()` instead of
43+
`ArrayPrototypeSlice(list)` is caught by review, not by the linter.
3744
- File names are kebab-case; the name determines the `BuiltinId` enum value
3845
(`promise-proxy.js``kPromiseProxy`) and the script origin. New files must
3946
also be added to `tools/js2c-inputs.xcfilelist` — the build fails with an
4047
explicit message if that list drifts out of sync (`js2c.mjs --filelist`).
48+
49+
## primordials
50+
51+
`primordials.js` runs first in every isolate — lazily, on the first
52+
`RunBuiltin` call, which happens during runtime init — and its frozen,
53+
null-prototype return value is cached per isolate (`Caches::Primordials`) and
54+
handed to every other builtin. Builtins that compile late (`smart-stringify`
55+
is compiled on the first object logged) therefore still see intrinsics as they
56+
were before user code ran.
57+
58+
Naming follows Node: statics keep their path (`JSONStringify`,
59+
`ObjectDefineProperty`), instance methods are **uncurried** so the receiver
60+
becomes the first argument:
61+
62+
```js
63+
ArrayPrototypeSlice(list, 1) // not list.slice(1)
64+
FunctionPrototypeCall(cb, this, event) // not cb.call(this, event)
65+
```
66+
67+
Uncurrying is `Function.prototype.bind.bind(Function.prototype.call)`, which on
68+
the jitless configuration the runtime ships is both faster than a captured
69+
`fn.call(...)` and immune to a replaced `Function.prototype.call`.
70+
71+
Add only what a builtin actually needs; this is not a mirror of Node's list.
72+
Plain constructor calls made once at init time (`new Map()` while
73+
bootstrapping) may stay direct — the rule targets code in closures that
74+
outlive init.

NativeScript/runtime/js/blob-url.js

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,25 @@
1+
const {
2+
Map,
3+
MapPrototypeDelete,
4+
MapPrototypeGet,
5+
MapPrototypeSet,
6+
ObjectDefineProperty,
7+
StringPrototypeToLowerCase,
8+
} = primordials;
9+
10+
// The searchParams accessor below outlives init, so the constructor it reaches
11+
// for is captured now rather than looked up on the global at call time.
12+
const URLSearchParamsCtor = URLSearchParams;
13+
114
const BLOB_STORE = new Map();
215
URL.createObjectURL = function (object, options = null) {
316
try {
17+
// Blob/File come from the app layer, not the runtime, so these stay
18+
// live lookups; the catch below covers them not existing yet.
419
if (object instanceof Blob || object instanceof File) {
5-
const id = NSUUID.UUID().UUIDString.toLowerCase();
20+
const id = StringPrototypeToLowerCase(NSUUID.UUID().UUIDString);
621
const ret = `blob:nativescript/${id}`;
7-
BLOB_STORE.set(ret, {
22+
MapPrototypeSet(BLOB_STORE, ret, {
823
blob: object,
924
type: object?.type,
1025
ext: options?.ext,
@@ -17,18 +32,18 @@ URL.createObjectURL = function (object, options = null) {
1732
return null;
1833
};
1934
URL.revokeObjectURL = function (url) {
20-
BLOB_STORE.delete(url);
35+
MapPrototypeDelete(BLOB_STORE, url);
2136
};
2237
const InternalAccessor = class {};
2338
InternalAccessor.getData = function (url) {
24-
return BLOB_STORE.get(url);
39+
return MapPrototypeGet(BLOB_STORE, url);
2540
};
2641
URL.InternalAccessor = InternalAccessor;
27-
Object.defineProperty(URL.prototype, 'searchParams', {
42+
ObjectDefineProperty(URL.prototype, 'searchParams', {
2843
get() {
2944
if (this._searchParams == null) {
30-
this._searchParams = new URLSearchParams(this.search);
31-
Object.defineProperty(this._searchParams, '_url', {
45+
this._searchParams = new URLSearchParamsCtor(this.search);
46+
ObjectDefineProperty(this._searchParams, '_url', {
3247
enumerable: false,
3348
writable: false,
3449
value: this,
Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
function __extends(d, b) {
1+
const { ObjectCreate, ObjectPrototypeHasOwnProperty } = primordials;
2+
function __extends(d, b) {
23
for (var p in b) {
3-
if (b.hasOwnProperty(p)) {
4+
if (ObjectPrototypeHasOwnProperty(b, p)) {
45
d[p] = b[p];
56
}
67
}
78
function __() { this.constructor = d; }
8-
d.prototype = b === null ? Object.create(b) :
9+
d.prototype = b === null ? ObjectCreate(b) :
910
(__.prototype = b.prototype, new __());
10-
}
11+
}
1112
return __extends;

NativeScript/runtime/js/error-events.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,29 @@
11
"use strict";
22

33
const { globalTarget, nativeReportFatal } = binding;
4+
const { FunctionPrototypeCall, ObjectCreate, String, TypeError } = primordials;
45
var g = globalThis;
56
var Event = g.Event;
67

78
function ErrorEvent(type, opts) {
89
opts = opts || {};
9-
Event.call(this, type, opts);
10+
FunctionPrototypeCall(Event, this, type, opts);
1011
this.message = opts.message !== undefined ? String(opts.message) : "";
1112
this.filename = opts.filename !== undefined ? String(opts.filename) : "";
1213
this.lineno = opts.lineno !== undefined ? (opts.lineno | 0) : 0;
1314
this.colno = opts.colno !== undefined ? (opts.colno | 0) : 0;
1415
this.error = opts.error !== undefined ? opts.error : null;
1516
}
16-
ErrorEvent.prototype = Object.create(Event.prototype);
17+
ErrorEvent.prototype = ObjectCreate(Event.prototype);
1718
ErrorEvent.prototype.constructor = ErrorEvent;
1819

1920
function PromiseRejectionEvent(type, opts) {
2021
opts = opts || {};
21-
Event.call(this, type, opts);
22+
FunctionPrototypeCall(Event, this, type, opts);
2223
this.promise = opts.promise;
2324
this.reason = opts.reason;
2425
}
25-
PromiseRejectionEvent.prototype = Object.create(Event.prototype);
26+
PromiseRejectionEvent.prototype = ObjectCreate(Event.prototype);
2627
PromiseRejectionEvent.prototype.constructor = PromiseRejectionEvent;
2728

2829
// A listener that throws must not stop other listeners: route the thrown

0 commit comments

Comments
 (0)