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.'); }