From 7e5f2f65180964c70d8a7b66be0c84a3957ada3f Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 30 Jul 2026 15:48:39 -0300 Subject: [PATCH] feat: Node-style primordials for runtime builtins Runtime builtins install closures that outlive init and are then reachable from app code, so every intrinsic they use at call time is something the app can replace. internal/primordials.js snapshots exactly the intrinsics the builtins need into a frozen null-prototype namespace, taken on the first RunBuiltin of an isolate (during runtime init) and cached per isolate; builtins now compile with a fourth fixed parameter, `primordials`. Instance methods are uncurried Node-style, so the receiver becomes the first argument. ESLint fails the lint on direct use of the captured statics and constructors. Mirrors NativeScript/ios#415. --- eslint.config.mjs | 45 +++- test-app/app/src/main/assets/app/mainpage.js | 2 + .../main/assets/app/tests/testPrimordials.js | 215 ++++++++++++++++++ test-app/runtime/CMakeLists.txt | 1 + .../runtime/src/main/cpp/BuiltinLoader.cpp | 81 ++++++- test-app/runtime/src/main/cpp/BuiltinLoader.h | 11 +- .../runtime/src/main/cpp/IsolateDisposer.cpp | 2 + test-app/runtime/src/main/cpp/js/README.md | 47 +++- test-app/runtime/src/main/cpp/js/blob-url.js | 26 ++- .../runtime/src/main/cpp/js/error-events.js | 9 +- test-app/runtime/src/main/cpp/js/events.js | 25 +- .../runtime/src/main/cpp/js/json-helper.js | 16 +- .../src/main/cpp/js/message-loop-timer.js | 16 +- .../runtime/src/main/cpp/js/primordials.js | 54 +++++ .../src/main/cpp/js/smart-stringify.js | 8 +- test-app/runtime/src/main/cpp/js/weak-ref.js | 14 +- 16 files changed, 517 insertions(+), 55 deletions(-) create mode 100644 test-app/app/src/main/assets/app/tests/testPrimordials.js create mode 100644 test-app/runtime/src/main/cpp/js/primordials.js diff --git a/eslint.config.mjs b/eslint.config.mjs index 96e48806f..655409db7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,11 +1,37 @@ // Lint setup for the runtime's builtin JavaScript // (test-app/runtime/src/main/cpp/js). Each file is compiled by BuiltinLoader -// as a FUNCTION BODY with the fixed parameters `exports`, `module` and -// `binding` (see that directory's README.md), which are declared as globals -// here. no-undef is the typo net for binding-bag destructures and -// native-global usage alike. +// as a FUNCTION BODY with the fixed parameters `exports`, `module`, `binding` +// and `primordials` (see that directory's README.md), which are declared as +// globals here. no-undef is the typo net for binding-bag destructures and +// native-global usage alike; no-restricted-properties keeps the captured +// intrinsics from being read off the live globals again. import globals from 'globals'; +// Statics that primordials.js captures, mapped to their replacement. Instance +// methods (Array.prototype.slice and friends) cannot be matched by +// no-restricted-properties on the receiver, so uncurried use of those stays a +// review rule. +const capturedStatics = [ + ['Array', 'isArray', 'ArrayIsArray'], + ['JSON', 'stringify', 'JSONStringify'], + ['Object', 'create', 'ObjectCreate'], + ['Object', 'defineProperty', 'ObjectDefineProperty'], + ['Object', 'keys', 'ObjectKeys'], +]; + +// Captured constructors. A destructure from `primordials` shadows the global, +// so these only fire on the unguarded reference. +const restrictedGlobals = ['Date', 'Map', 'Proxy', 'String', 'TypeError'].map((name) => ({ + name, + message: `Destructure ${name} from primordials — builtins must not read intrinsics off globals user code can replace.`, +})); + +const restrictedProperties = capturedStatics.map(([object, property, primordial]) => ({ + object, + property, + message: `Use the ${primordial} primordial instead of ${object}.${property} — builtins must not read intrinsics off globals user code can replace.`, +})); + export default [ { files: ['test-app/runtime/src/main/cpp/js/**/*.js'], @@ -17,6 +43,7 @@ export default [ exports: 'readonly', module: 'readonly', binding: 'readonly', + primordials: 'readonly', global: 'readonly', console: 'readonly', URL: 'readonly', @@ -33,6 +60,16 @@ export default [ rules: { 'no-undef': 'error', 'no-unused-vars': ['error', { args: 'none', caughtErrors: 'none' }], + 'no-restricted-properties': ['error', ...restrictedProperties], + 'no-restricted-globals': ['error', ...restrictedGlobals], + }, + }, + { + // The file that does the capturing. + files: ['test-app/runtime/src/main/cpp/js/primordials.js'], + rules: { + 'no-restricted-properties': 'off', + 'no-restricted-globals': 'off', }, }, ]; diff --git a/test-app/app/src/main/assets/app/mainpage.js b/test-app/app/src/main/assets/app/mainpage.js index 2ff19f79d..ad71a72af 100644 --- a/test-app/app/src/main/assets/app/mainpage.js +++ b/test-app/app/src/main/assets/app/mainpage.js @@ -78,6 +78,8 @@ require('./tests/testErrorEvents'); require('./tests/testUnhandledRejections'); require('./tests/testEscapeException'); require('./tests/testUncaughtErrorPolicy'); +// Runtime builtins keep working when app code replaces the intrinsics they use +require('./tests/testPrimordials'); require("./tests/testConcurrentAccess"); require("./tests/testESModules.mjs"); diff --git a/test-app/app/src/main/assets/app/tests/testPrimordials.js b/test-app/app/src/main/assets/app/tests/testPrimordials.js new file mode 100644 index 000000000..48b62fffa --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testPrimordials.js @@ -0,0 +1,215 @@ +describe("primordials", function () { + const boom = function () { + throw new Error("intrinsic tampered"); + }; + + // Tampering with the intrinsics breaks Jasmine and most of the runtime as + // well, so the tampered window stays synchronous and assertion-free: + // results go into locals, the originals come back in a finally, and only + // then do the expectations run. Nothing inside the window may use an array + // method or `.call` either — plain indexing and direct calls only. + function withTampered(patches, body) { + const originals = []; + for (let i = 0; i < patches.length; i++) { + originals[i] = patches[i][0][patches[i][1]]; + } + try { + for (let i = 0; i < patches.length; i++) { + patches[i][0][patches[i][1]] = boom; + } + return body(); + } finally { + for (let i = 0; i < patches.length; i++) { + patches[i][0][patches[i][1]] = originals[i]; + } + } + } + + const arrayAndCall = [ + [Array.prototype, "slice"], + [Array.prototype, "indexOf"], + [Array.prototype, "push"], + [Array.prototype, "splice"], + [Function.prototype, "call"], + ]; + + it("the tampering used by this suite is actually observable", function () { + const outcome = withTampered(arrayAndCall, function () { + try { + [1, 2].slice(0); + return "no throw"; + } catch (e) { + return e.message; + } + }); + + expect(outcome).toBe("intrinsic tampered"); + expect([1, 2].slice(0).length).toBe(2); + }); + + it("global dispatchEvent delivers to every listener while intrinsics are tampered", function () { + const seen = []; + const first = function (e) { seen[seen.length] = "first:" + e.type; }; + const second = { handleEvent: function (e) { seen[seen.length] = "second:" + e.type; } }; + const event = new Event("primordials-dispatch"); + + global.addEventListener("primordials-dispatch", first); + global.addEventListener("primordials-dispatch", second); + + let dispatchResult; + try { + dispatchResult = withTampered(arrayAndCall, function () { + return global.dispatchEvent(event); + }); + } finally { + global.removeEventListener("primordials-dispatch", first); + global.removeEventListener("primordials-dispatch", second); + } + + expect(dispatchResult).toBe(true); + expect(seen.join(",")).toBe("first:primordials-dispatch,second:primordials-dispatch"); + }); + + it("addEventListener/removeEventListener and once work while intrinsics are tampered", function () { + const calls = []; + const persistent = function () { calls[calls.length] = "persistent"; }; + const onceOnly = function () { calls[calls.length] = "once"; }; + + try { + withTampered(arrayAndCall, function () { + global.addEventListener("primordials-registration", persistent); + global.addEventListener("primordials-registration", onceOnly, { once: true }); + global.dispatchEvent(new Event("primordials-registration")); + global.dispatchEvent(new Event("primordials-registration")); + global.removeEventListener("primordials-registration", persistent); + global.dispatchEvent(new Event("primordials-registration")); + }); + } finally { + global.removeEventListener("primordials-registration", persistent); + global.removeEventListener("primordials-registration", onceOnly); + } + + expect(calls.join(",")).toBe("persistent,once,persistent"); + }); + + it("reportError still reaches an error listener while intrinsics are tampered", function () { + let received = null; + // preventDefault keeps the unhandled tail (which aborts the process) + // out of the picture. + const onError = function (e) { + received = e; + e.preventDefault(); + }; + const error = new Error("primordials-report"); + + global.addEventListener("error", onError); + try { + withTampered(arrayAndCall, function () { + global.reportError(error); + }); + } finally { + global.removeEventListener("error", onError); + } + + expect(received).not.toBeNull(); + expect(received.type).toBe("error"); + expect(received.error).toBe(error); + expect(received.message).toBe("primordials-report"); + }); + + it("console.log of a circular object neither throws nor crashes with JSON.stringify tampered", function () { + // The smart-stringify builtin both calls JSON.stringify and tracks + // already-visited objects with Array.prototype.indexOf/push. Its output + // is not reachable from JS and JsonStringifyObject swallows a throwing + // stringify, so this only pins down that the tampered path stays + // non-fatal; the primordial routing itself is covered by review. + const circular = { name: "primordials" }; + circular.self = circular; + + let threw = null; + try { + withTampered([ + [JSON, "stringify"], + [Array.prototype, "indexOf"], + [Array.prototype, "push"], + ], function () { + console.log(circular); + }); + } catch (e) { + threw = e; + } + + expect(threw).toBeNull(); + }); + + it("the searchParams accessor works while Object.defineProperty is tampered", function () { + const url = new URL("https://example.com/path?a=1"); + + let readBack = null; + let searchAfterAppend = null; + let threw = null; + try { + withTampered([[Object, "defineProperty"]], function () { + const params = url.searchParams; + readBack = params.get("a"); + params.append("b", "2"); + searchAfterAppend = url.search; + }); + } catch (e) { + threw = e; + } + + expect(threw).toBeNull(); + expect(readBack).toBe("1"); + expect(searchAfterAppend).toBe("?a=1&b=2"); + }); + + it("revokeObjectURL and InternalAccessor.getData work while Map methods are tampered", function () { + let data; + let threw = null; + try { + withTampered([ + [Map.prototype, "get"], + [Map.prototype, "set"], + [Map.prototype, "delete"], + ], function () { + URL.revokeObjectURL("blob:nativescript/primordials-missing"); + data = URL.InternalAccessor.getData("blob:nativescript/primordials-missing"); + }); + } catch (e) { + threw = e; + } + + expect(threw).toBeNull(); + expect(data).toBeUndefined(); + }); + + it("org.json.JSONObject.from works while the intrinsics json-helper uses are tampered", function () { + const source = { + text: "primordials", + when: new Date(1570696661136), + list: [1, 2], + }; + + let converted = null; + let threw = null; + try { + withTampered([ + [Array, "isArray"], + [Array.prototype, "forEach"], + [Object, "keys"], + [Date.prototype, "toJSON"], + ], function () { + converted = org.json.JSONObject.from(source); + }); + } catch (e) { + threw = e; + } + + expect(threw).toBeNull(); + expect(converted instanceof org.json.JSONObject).toBe(true); + expect(converted.getString("text")).toBe("primordials"); + expect(converted.getString("when")).toBe("2019-10-10T08:37:41.136Z"); + expect(converted.getJSONArray("list").length()).toBe(2); + }); +}); diff --git a/test-app/runtime/CMakeLists.txt b/test-app/runtime/CMakeLists.txt index a3b715b0b..31797cb97 100644 --- a/test-app/runtime/CMakeLists.txt +++ b/test-app/runtime/CMakeLists.txt @@ -69,6 +69,7 @@ set(RUNTIME_BUILTIN_JS ${RUNTIME_BUILTIN_JS_DIR}/events.js ${RUNTIME_BUILTIN_JS_DIR}/json-helper.js ${RUNTIME_BUILTIN_JS_DIR}/message-loop-timer.js + ${RUNTIME_BUILTIN_JS_DIR}/primordials.js ${RUNTIME_BUILTIN_JS_DIR}/require-factory.js ${RUNTIME_BUILTIN_JS_DIR}/smart-stringify.js ${RUNTIME_BUILTIN_JS_DIR}/weak-ref.js diff --git a/test-app/runtime/src/main/cpp/BuiltinLoader.cpp b/test-app/runtime/src/main/cpp/BuiltinLoader.cpp index e9498555c..81e81cebf 100644 --- a/test-app/runtime/src/main/cpp/BuiltinLoader.cpp +++ b/test-app/runtime/src/main/cpp/BuiltinLoader.cpp @@ -4,6 +4,7 @@ #include #include "ArgConverter.h" +#include "robin_hood.h" using namespace v8; @@ -21,13 +22,22 @@ std::vector builtinCache[static_cast(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`, and natives arrive as properties of the - * `binding` bag (Node's internalBinding idiom) for each file to destructure. + * `module.exports`/`exports`, 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* kModuleParamName = "module"; constexpr const char* kBindingParamName = "binding"; -constexpr size_t kParamCount = 3; +constexpr const char* kPrimordialsParamName = "primordials"; +constexpr size_t kParamCount = 4; + +/* + * Per-isolate intrinsics snapshot. Worker runtimes initialize on their own + * threads, so every access is under the mutex. + */ +std::mutex primordialsMutex; +robin_hood::unordered_map*> isolateToPrimordials; MaybeLocal CompileBuiltin(Local context, BuiltinId id) { Isolate* isolate = v8::Isolate::GetCurrent(); @@ -48,7 +58,8 @@ MaybeLocal CompileBuiltin(Local context, BuiltinId id) { Local params[] = { ArgConverter::ConvertToV8String(isolate, kExportsParamName), ArgConverter::ConvertToV8String(isolate, kModuleParamName), - ArgConverter::ConvertToV8String(isolate, kBindingParamName)}; + ArgConverter::ConvertToV8String(isolate, kBindingParamName), + ArgConverter::ConvertToV8String(isolate, kPrimordialsParamName)}; Local fn; if (!blob.empty()) { @@ -86,10 +97,8 @@ MaybeLocal CompileBuiltin(Local context, BuiltinId id) { return fn; } -} // namespace - -MaybeLocal BuiltinLoader::RunBuiltin(Local context, BuiltinId id, - Local binding) { +MaybeLocal CallBuiltin(Local context, BuiltinId id, Local binding, + Local primordials) { Isolate* isolate = v8::Isolate::GetCurrent(); Local fn; @@ -105,7 +114,8 @@ MaybeLocal BuiltinLoader::RunBuiltin(Local context, BuiltinId id } Local args[] = {exportsObj, moduleObj, - binding.IsEmpty() ? Undefined(isolate).As() : binding}; + binding.IsEmpty() ? Undefined(isolate).As() : binding, + primordials}; if (fn->Call(context, Undefined(isolate), static_cast(kParamCount), args).IsEmpty()) { return MaybeLocal(); } @@ -113,4 +123,57 @@ MaybeLocal BuiltinLoader::RunBuiltin(Local context, BuiltinId id 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. Later + * builtins (smart-stringify compiles lazily, on the first object logged) get + * the same pristine snapshot. + */ +MaybeLocal GetPrimordials(Local context) { + Isolate* isolate = v8::Isolate::GetCurrent(); + + { + std::lock_guard lock(primordialsMutex); + auto it = isolateToPrimordials.find(isolate); + if (it != isolateToPrimordials.end()) { + return it->second->Get(isolate); + } + } + + Local result; + if (!CallBuiltin(context, BuiltinId::kPrimordials, Local(), Undefined(isolate)) + .ToLocal(&result) || + !result->IsObject()) { + return MaybeLocal(); + } + + Local primordials = result.As(); + { + std::lock_guard lock(primordialsMutex); + isolateToPrimordials.emplace(isolate, new Persistent(isolate, primordials)); + } + return primordials; +} + +} // namespace + +MaybeLocal BuiltinLoader::RunBuiltin(Local context, BuiltinId id, + Local binding) { + Local primordials; + if (!GetPrimordials(context).ToLocal(&primordials)) { + return MaybeLocal(); + } + + return CallBuiltin(context, id, binding, primordials); +} + +void BuiltinLoader::onDisposeIsolate(Isolate* isolate) { + std::lock_guard lock(primordialsMutex); + auto it = isolateToPrimordials.find(isolate); + if (it != isolateToPrimordials.end()) { + delete it->second; + isolateToPrimordials.erase(it); + } +} + } // namespace tns diff --git a/test-app/runtime/src/main/cpp/BuiltinLoader.h b/test-app/runtime/src/main/cpp/BuiltinLoader.h index 944e07684..89c242a5c 100644 --- a/test-app/runtime/src/main/cpp/BuiltinLoader.h +++ b/test-app/runtime/src/main/cpp/BuiltinLoader.h @@ -10,9 +10,12 @@ class BuiltinLoader { public: /* * Compiles the builtin identified by id as a function body with the fixed - * parameters `exports`, `module` and `binding` (Node's module wrapper plus - * its internalBinding idiom), calls it with the given bag of natives (or - * undefined when omitted), and returns the resulting `module.exports`. + * parameters `exports`, `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`. 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/.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 @@ -22,6 +25,8 @@ class BuiltinLoader { static v8::MaybeLocal RunBuiltin( v8::Local context, BuiltinId id, v8::Local binding = v8::Local()); + + static void onDisposeIsolate(v8::Isolate* isolate); }; } // namespace tns diff --git a/test-app/runtime/src/main/cpp/IsolateDisposer.cpp b/test-app/runtime/src/main/cpp/IsolateDisposer.cpp index a322edefc..1896b64f8 100644 --- a/test-app/runtime/src/main/cpp/IsolateDisposer.cpp +++ b/test-app/runtime/src/main/cpp/IsolateDisposer.cpp @@ -4,6 +4,7 @@ #include "IsolateDisposer.h" #include "ArgConverter.h" +#include "BuiltinLoader.h" #include "JSONObjectHelper.h" #include "MetadataNode.h" #include "V8GlobalHelpers.h" @@ -18,6 +19,7 @@ namespace tns { tns::V8GlobalHelpers::onDisposeIsolate(isolate); tns::Console::onDisposeIsolate(isolate); tns::JSONObjectHelper::onDisposeIsolate(isolate); + tns::BuiltinLoader::onDisposeIsolate(isolate); // clear all isolate bound objects std::lock_guard lock(isolateBoundObjectsMutex_); auto it = isolateBoundObjects_.find(isolate); diff --git a/test-app/runtime/src/main/cpp/js/README.md b/test-app/runtime/src/main/cpp/js/README.md index 22a6e0da9..bf23d23a0 100644 --- a/test-app/runtime/src/main/cpp/js/README.md +++ b/test-app/runtime/src/main/cpp/js/README.md @@ -9,16 +9,19 @@ build time a CMake custom command runs `tools/js2c.mjs`, which embeds them into ## Contract (Node's module wrapper + internalBinding idiom) Every file is compiled as a **function body** via `v8::ScriptCompiler::CompileFunction` -with the fixed parameters `exports`, `module` and `binding`: +with the fixed parameters `exports`, `module`, `binding` and `primordials`: ```js const { someNative, anotherNative } = binding; +const { ArrayPrototypeSlice, ObjectCreate } = primordials; module.exports = somethingTheCallSiteNeeds; ``` - `binding` is a plain object of natives built by the C++ call site; a file that needs nothing from C++ simply doesn't mention it. +- `primordials` is the frozen intrinsics snapshot built by `primordials.js` + (see below), the same object for every builtin in an isolate. - **`module.exports` is the export channel** — whatever it holds when the file finishes is what `RunBuiltin` hands back to C++ (used for factory functions and init results). Both CommonJS styles work: replace the whole export with @@ -28,19 +31,53 @@ module.exports = somethingTheCallSiteNeeds; every tool that isn't reading this repo's ESLint config (editors' TS server, prettier, review bots) rejects the file as invalid JavaScript. - Strict mode is per-file: start the file with `"use strict";` to opt in. -- Destructure `binding` once, at the top of the file, so the file's native - dependencies are visible and greppable. +- Destructure `binding` and `primordials` once, at the top of the file, so the + file's dependencies are visible and greppable. ## Rules - Run at isolate init, before any user code: capture any global you rely on (e.g. `globalThis.Event`) eagerly so later monkey-patching can't break you. + For intrinsics that is what `primordials` is; for everything else + (`URLSearchParams`, …) capture it into a file-level `const`. - No `import`/`export` — these are classic function bodies, not modules. - ESLint (`eslint.config.mjs` at the repo root, `npm run lint`) declares - `exports`, `module`, `binding` and the reachable native globals; `no-undef` - is the typo net. If a builtin starts using a new native global, add it there. + `exports`, `module`, `binding`, `primordials` and the reachable native + globals; `no-undef` is the typo net. If a builtin starts using a new native + global, add it there. `no-restricted-properties` fails the lint on direct use + of the captured statics (`JSON.stringify`, `Object.defineProperty`, …). + Uncurried instance methods can't be matched that way, so `list.slice()` + instead of `ArrayPrototypeSlice(list)` is caught by review, not by the + linter. - File names are kebab-case; the name determines the `BuiltinId` enum value (`weak-ref.js` → `kWeakRef`) and the script origin. New files must also be added to `RUNTIME_BUILTIN_JS` in `test-app/runtime/CMakeLists.txt` — the build fails with an explicit message if that list drifts out of sync (`js2c.mjs --check-dir`). + +## primordials + +`primordials.js` runs first in every isolate — lazily, on the first +`RunBuiltin` call, which happens during runtime init — and its frozen, +null-prototype export is cached per isolate (`BuiltinLoader`, released from +`disposeIsolate`) and handed to every other builtin. Builtins that compile +late (`smart-stringify` is compiled on the first object logged) therefore +still see intrinsics as they were before user code ran. + +Naming follows Node: statics keep their path (`JSONStringify`, +`ObjectDefineProperty`), instance methods are **uncurried** so the receiver +becomes the first argument: + +```js +ArrayPrototypeSlice(list, 1) // not list.slice(1) +FunctionPrototypeCall(cb, this, event) // not cb.call(this, event) +``` + +Uncurrying is `Function.prototype.bind.bind(Function.prototype.call)`, which is +both faster than a captured `fn.call(...)` and immune to a replaced +`Function.prototype.call`. + +Add only what a builtin actually needs; this is not a mirror of Node's list. +Plain constructor calls made once at init time (`new Map()` while +bootstrapping) may stay direct — the rule targets code in closures that +outlive init. diff --git a/test-app/runtime/src/main/cpp/js/blob-url.js b/test-app/runtime/src/main/cpp/js/blob-url.js index 0f0ce4fdd..e37ca9a0e 100644 --- a/test-app/runtime/src/main/cpp/js/blob-url.js +++ b/test-app/runtime/src/main/cpp/js/blob-url.js @@ -1,10 +1,24 @@ +const { + Map, + MapPrototypeDelete, + MapPrototypeGet, + MapPrototypeSet, + ObjectDefineProperty, +} = primordials; + +// The searchParams accessor below outlives init, so the constructor it reaches +// for is captured now rather than looked up on the global at call time. +const URLSearchParamsCtor = URLSearchParams; + const BLOB_STORE = new Map(); URL.createObjectURL = function (object, options = null) { try { + // Blob/File come from the app layer, not the runtime, so these stay + // live lookups; the catch below covers them not existing yet. if (object instanceof Blob || object instanceof File) { const id = java.util.UUID.randomUUID().toString(); const ret = `blob:nativescript/${id}`; - BLOB_STORE.set(ret, { + MapPrototypeSet(BLOB_STORE, ret, { blob: object, type: object?.type, ext: options?.ext, @@ -17,18 +31,18 @@ URL.createObjectURL = function (object, options = null) { return null; }; URL.revokeObjectURL = function (url) { - BLOB_STORE.delete(url); + MapPrototypeDelete(BLOB_STORE, url); }; const InternalAccessor = class {}; InternalAccessor.getData = function (url) { - return BLOB_STORE.get(url); + return MapPrototypeGet(BLOB_STORE, url); }; URL.InternalAccessor = InternalAccessor; -Object.defineProperty(URL.prototype, 'searchParams', { +ObjectDefineProperty(URL.prototype, 'searchParams', { get() { if (this._searchParams == null) { - this._searchParams = new URLSearchParams(this.search); - Object.defineProperty(this._searchParams, '_url', { + this._searchParams = new URLSearchParamsCtor(this.search); + ObjectDefineProperty(this._searchParams, '_url', { enumerable: false, writable: false, value: this, diff --git a/test-app/runtime/src/main/cpp/js/error-events.js b/test-app/runtime/src/main/cpp/js/error-events.js index 43b4e3f53..02788ef22 100644 --- a/test-app/runtime/src/main/cpp/js/error-events.js +++ b/test-app/runtime/src/main/cpp/js/error-events.js @@ -1,28 +1,29 @@ "use strict"; const { globalTarget, nativeReportFatal } = binding; +const { FunctionPrototypeCall, ObjectCreate, String, TypeError } = primordials; var g = globalThis; var Event = g.Event; function ErrorEvent(type, opts) { opts = opts || {}; - Event.call(this, type, opts); + FunctionPrototypeCall(Event, this, type, opts); this.message = opts.message !== undefined ? String(opts.message) : ""; this.filename = opts.filename !== undefined ? String(opts.filename) : ""; this.lineno = opts.lineno !== undefined ? (opts.lineno | 0) : 0; this.colno = opts.colno !== undefined ? (opts.colno | 0) : 0; this.error = opts.error !== undefined ? opts.error : null; } -ErrorEvent.prototype = Object.create(Event.prototype); +ErrorEvent.prototype = ObjectCreate(Event.prototype); ErrorEvent.prototype.constructor = ErrorEvent; function PromiseRejectionEvent(type, opts) { opts = opts || {}; - Event.call(this, type, opts); + FunctionPrototypeCall(Event, this, type, opts); this.promise = opts.promise; this.reason = opts.reason; } -PromiseRejectionEvent.prototype = Object.create(Event.prototype); +PromiseRejectionEvent.prototype = ObjectCreate(Event.prototype); PromiseRejectionEvent.prototype.constructor = PromiseRejectionEvent; // A listener that throws must not stop other listeners: route the thrown diff --git a/test-app/runtime/src/main/cpp/js/events.js b/test-app/runtime/src/main/cpp/js/events.js index 612f35701..28b57fcfc 100644 --- a/test-app/runtime/src/main/cpp/js/events.js +++ b/test-app/runtime/src/main/cpp/js/events.js @@ -1,4 +1,13 @@ "use strict"; +const { + ArrayPrototypeIndexOf, + ArrayPrototypePush, + ArrayPrototypeSlice, + ArrayPrototypeSplice, + FunctionPrototypeCall, + ObjectCreate, + String, +} = primordials; var g = globalThis; function Event(type, opts) { @@ -29,7 +38,7 @@ Event.prototype.stopImmediatePropagation = function () { // user code runs); until then a thrown listener is swallowed. var reportListenerError = function (e) {}; -function EventTargetImpl() { this._listeners = Object.create(null); } +function EventTargetImpl() { this._listeners = ObjectCreate(null); } EventTargetImpl.prototype.addEventListener = function (type, callback, options) { if (callback === null || callback === undefined) { return; } type = String(type); @@ -45,7 +54,7 @@ EventTargetImpl.prototype.addEventListener = function (type, callback, options) for (var i = 0; i < list.length; i++) { if (list[i].callback === callback && list[i].capture === capture) { return; } } - list.push({ callback: callback, once: once, capture: capture }); + ArrayPrototypePush(list, { callback: callback, once: once, capture: capture }); }; EventTargetImpl.prototype.removeEventListener = function (type, callback, options) { type = String(type); @@ -59,7 +68,7 @@ EventTargetImpl.prototype.removeEventListener = function (type, callback, option if (!list) { return; } for (var i = 0; i < list.length; i++) { if (list[i].callback === callback && list[i].capture === capture) { - list.splice(i, 1); + ArrayPrototypeSplice(list, i, 1); return; } } @@ -71,16 +80,16 @@ EventTargetImpl.prototype.dispatchEvent = function (event) { if (list) { // Snapshot so listeners added during dispatch are not invoked and // registration order is preserved. - var snapshot = list.slice(); + var snapshot = ArrayPrototypeSlice(list); for (var i = 0; i < snapshot.length; i++) { var entry = snapshot[i]; - var idx = list.indexOf(entry); + var idx = ArrayPrototypeIndexOf(list, entry); if (idx === -1) { continue; } // removed since snapshot - if (entry.once) { list.splice(idx, 1); } + if (entry.once) { ArrayPrototypeSplice(list, idx, 1); } var cb = entry.callback; try { if (typeof cb === "function") { - cb.call(this, event); + FunctionPrototypeCall(cb, this, event); } else if (cb && typeof cb.handleEvent === "function") { cb.handleEvent(event); } @@ -116,7 +125,7 @@ g.dispatchEvent = function (event) { return globalTarget.dispatchEvent(event); }; -function EventTarget() { EventTargetImpl.call(this); } +function EventTarget() { FunctionPrototypeCall(EventTargetImpl, this); } EventTarget.prototype.addEventListener = EventTargetImpl.prototype.addEventListener; EventTarget.prototype.removeEventListener = EventTargetImpl.prototype.removeEventListener; EventTarget.prototype.dispatchEvent = EventTargetImpl.prototype.dispatchEvent; diff --git a/test-app/runtime/src/main/cpp/js/json-helper.js b/test-app/runtime/src/main/cpp/js/json-helper.js index b082c2c1d..bc38e7f9b 100644 --- a/test-app/runtime/src/main/cpp/js/json-helper.js +++ b/test-app/runtime/src/main/cpp/js/json-helper.js @@ -1,3 +1,11 @@ +const { + ArrayIsArray, + ArrayPrototypeForEach, + Date, + DatePrototypeToJSON, + ObjectKeys, +} = primordials; + function serialize(data) { let store; switch (typeof data) { @@ -12,17 +20,17 @@ function serialize(data) { } if (data instanceof Date) { - return data.toJSON(); + return DatePrototypeToJSON(data); } - if (Array.isArray(data)) { + if (ArrayIsArray(data)) { store = new org.json.JSONArray(); - data.forEach((item) => store.put(serialize(item))); + ArrayPrototypeForEach(data, (item) => store.put(serialize(item))); return store; } store = new org.json.JSONObject(); - Object.keys(data).forEach((key) => store.put(key, serialize(data[key]))); + ArrayPrototypeForEach(ObjectKeys(data), (key) => store.put(key, serialize(data[key]))); return store; } default: diff --git a/test-app/runtime/src/main/cpp/js/message-loop-timer.js b/test-app/runtime/src/main/cpp/js/message-loop-timer.js index 44da362b5..a1ed2b330 100644 --- a/test-app/runtime/src/main/cpp/js/message-loop-timer.js +++ b/test-app/runtime/src/main/cpp/js/message-loop-timer.js @@ -1,4 +1,11 @@ const { messageLoopTimerStart, messageLoopTimerStop } = binding; +const { + ArrayPrototypeIndexOf, + FunctionPrototypeApply, + PromisePrototypeCatch, + PromisePrototypeThen, + Proxy, +} = primordials; // We proxy the WebAssembly's compile, compileStreaming, instantiate and // instantiateStreaming methods so that they can start and stop a @@ -16,17 +23,18 @@ global.WebAssembly = new Proxy(WebAssembly, { "instantiateStreaming" ]; - if (proxyMethods.indexOf(name) < 0) { + if (ArrayPrototypeIndexOf(proxyMethods, name) < 0) { return origMethod; } return function (...args) { messageLoopTimerStart(); - let result = origMethod.apply(this, args); - return result.then(x => { + let result = FunctionPrototypeApply(origMethod, this, args); + let settled = PromisePrototypeThen(result, x => { messageLoopTimerStop(); return x; - }).catch(e => { + }); + return PromisePrototypeCatch(settled, e => { messageLoopTimerStop(); throw e; }); diff --git a/test-app/runtime/src/main/cpp/js/primordials.js b/test-app/runtime/src/main/cpp/js/primordials.js new file mode 100644 index 000000000..442e9488c --- /dev/null +++ b/test-app/runtime/src/main/cpp/js/primordials.js @@ -0,0 +1,54 @@ +"use strict"; + +// Snapshot of the intrinsics the other builtins depend on, taken before any +// user code can reach the globals. Runs first and is handed to every other +// builtin as the fourth fixed parameter. +// +// Instance methods are exposed "uncurried" (Node's idiom): the receiver +// becomes the first argument, so `ArrayPrototypeSlice(list, 0)` reads the +// captured Array.prototype.slice directly instead of a property of whatever +// `list.slice` resolves to at call time. + +const FunctionPrototypeCall = Function.prototype.call; +const FunctionPrototypeBind = Function.prototype.bind; +const FunctionPrototypeApply = Function.prototype.apply; + +// bind() with `this` pinned to call(): uncurryThis(fn) === fn.call.bind(fn), +// but without reading `fn.call`. +const uncurryThis = FunctionPrototypeBind.bind(FunctionPrototypeCall); + +// Named `intrinsics` rather than `primordials`: the latter is this file's own +// (unused) parameter. +const intrinsics = { + // Constructors. + Date, + Map, + Proxy, + String, + TypeError, + + // Statics. + ArrayIsArray: Array.isArray, + JSONStringify: JSON.stringify, + ObjectCreate: Object.create, + ObjectDefineProperty: Object.defineProperty, + ObjectKeys: Object.keys, + + // Instance methods, uncurried. + ArrayPrototypeForEach: uncurryThis(Array.prototype.forEach), + ArrayPrototypeIndexOf: uncurryThis(Array.prototype.indexOf), + ArrayPrototypePush: uncurryThis(Array.prototype.push), + ArrayPrototypeSlice: uncurryThis(Array.prototype.slice), + ArrayPrototypeSplice: uncurryThis(Array.prototype.splice), + DatePrototypeToJSON: uncurryThis(Date.prototype.toJSON), + FunctionPrototypeApply: uncurryThis(FunctionPrototypeApply), + FunctionPrototypeCall: uncurryThis(FunctionPrototypeCall), + MapPrototypeDelete: uncurryThis(Map.prototype.delete), + MapPrototypeGet: uncurryThis(Map.prototype.get), + MapPrototypeSet: uncurryThis(Map.prototype.set), + PromisePrototypeCatch: uncurryThis(Promise.prototype.catch), + PromisePrototypeThen: uncurryThis(Promise.prototype.then), +}; + +Object.setPrototypeOf(intrinsics, null); +module.exports = Object.freeze(intrinsics); diff --git a/test-app/runtime/src/main/cpp/js/smart-stringify.js b/test-app/runtime/src/main/cpp/js/smart-stringify.js index b7b06098f..5824f9d1c 100644 --- a/test-app/runtime/src/main/cpp/js/smart-stringify.js +++ b/test-app/runtime/src/main/cpp/js/smart-stringify.js @@ -1,17 +1,19 @@ +const { ArrayPrototypeIndexOf, ArrayPrototypePush, JSONStringify } = primordials; + function smartStringify(object) { const seen = []; var replacer = function (key, value) { if (value != null && typeof value == "object") { - if (seen.indexOf(value) >= 0) { + if (ArrayPrototypeIndexOf(seen, value) >= 0) { if (key) { return "[Circular]"; } return; } - seen.push(value); + ArrayPrototypePush(seen, value); } return value; }; - return JSON.stringify(object, replacer, 2); + return JSONStringify(object, replacer, 2); } module.exports = smartStringify; diff --git a/test-app/runtime/src/main/cpp/js/weak-ref.js b/test-app/runtime/src/main/cpp/js/weak-ref.js index 01e9fe035..eed4137e1 100644 --- a/test-app/runtime/src/main/cpp/js/weak-ref.js +++ b/test-app/runtime/src/main/cpp/js/weak-ref.js @@ -1,9 +1,13 @@ -global.WeakRef.prototype.get = global.WeakRef.prototype.deref; -global.WeakRef.prototype.__hasWarnedAboutClear = false; -global.WeakRef.prototype.clear = () => { - if(global.WeakRef.prototype.__hasWarnedAboutClear) { +// `clear` outlives init, so the prototype it flags is captured now rather than +// reached through the global at call time. +const WeakRefPrototype = global.WeakRef.prototype; + +WeakRefPrototype.get = WeakRefPrototype.deref; +WeakRefPrototype.__hasWarnedAboutClear = false; +WeakRefPrototype.clear = () => { + if (WeakRefPrototype.__hasWarnedAboutClear) { return; } - global.WeakRef.prototype.__hasWarnedAboutClear = true; + WeakRefPrototype.__hasWarnedAboutClear = true; console.warn('WeakRef.clear() is non-standard and has been deprecated. It does nothing and the call can be safely removed.'); }