diff --git a/.gitignore b/.gitignore index 67618ca90..215d72492 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,10 @@ thumbs.db .classpath android-runtime.iml + +# Emitted by tools/js2c.mjs from test-app/runtime/src/main/cpp/js during the build. +test-app/runtime/src/main/cpp/generated/ + test-app/build-tools/*.log test-app/analytics/build-statistics.json package-lock.json diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 000000000..96e48806f --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,38 @@ +// 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. +import globals from 'globals'; + +export default [ + { + files: ['test-app/runtime/src/main/cpp/js/**/*.js'], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'script', + globals: { + ...globals.es2021, + exports: 'readonly', + module: 'readonly', + binding: 'readonly', + global: 'readonly', + console: 'readonly', + URL: 'readonly', + URLSearchParams: 'readonly', + Blob: 'readonly', + File: 'readonly', + WebAssembly: 'readonly', + // Java package roots resolved through the metadata interceptor at + // runtime: + java: 'readonly', + org: 'readonly', + }, + }, + rules: { + 'no-undef': 'error', + 'no-unused-vars': ['error', { args: 'none', caughtErrors: 'none' }], + }, + }, +]; diff --git a/package.json b/package.json index 3e1ae3033..1c0a019f9 100644 --- a/package.json +++ b/package.json @@ -27,11 +27,14 @@ }, "scripts": { "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s", + "lint": "eslint test-app/runtime/src/main/cpp/js", "version": "npm run changelog && git add CHANGELOG.md" }, "devDependencies": { "conventional-changelog-cli": "^2.1.1", "dayjs": "^1.11.7", + "eslint": "^9.15.0", + "globals": "^15.12.0", "semver": "^7.5.0" } } diff --git a/test-app/runtime/CMakeLists.txt b/test-app/runtime/CMakeLists.txt index 63a33e196..a3b715b0b 100644 --- a/test-app/runtime/CMakeLists.txt +++ b/test-app/runtime/CMakeLists.txt @@ -58,6 +58,41 @@ include_directories( src/main/cpp/ada ) +# The runtime's builtin JavaScript (src/main/cpp/js) embedded into a generated +# C++ table by tools/js2c.mjs. The list is explicit rather than globbed so that +# adding a file is a visible build change; --check-dir fails the build when it +# drifts from the directory contents. +set(RUNTIME_BUILTIN_JS_DIR ${PROJECT_SOURCE_DIR}/src/main/cpp/js) +set(RUNTIME_BUILTIN_JS + ${RUNTIME_BUILTIN_JS_DIR}/blob-url.js + ${RUNTIME_BUILTIN_JS_DIR}/error-events.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}/require-factory.js + ${RUNTIME_BUILTIN_JS_DIR}/smart-stringify.js + ${RUNTIME_BUILTIN_JS_DIR}/weak-ref.js +) +set(RUNTIME_BUILTINS_GENERATED_DIR ${PROJECT_SOURCE_DIR}/src/main/cpp/generated) +get_filename_component(RUNTIME_BUILTINS_JS2C ${PROJECT_SOURCE_DIR}/../../tools/js2c.mjs ABSOLUTE) + +find_program(NODE_EXECUTABLE NAMES node nodejs) +if (NOT NODE_EXECUTABLE) + message(FATAL_ERROR "node was not found on PATH; it is required to generate RuntimeBuiltins") +endif () + +add_custom_command( + OUTPUT ${RUNTIME_BUILTINS_GENERATED_DIR}/RuntimeBuiltins.h + ${RUNTIME_BUILTINS_GENERATED_DIR}/RuntimeBuiltins.cpp + COMMAND ${NODE_EXECUTABLE} ${RUNTIME_BUILTINS_JS2C} + --out-dir ${RUNTIME_BUILTINS_GENERATED_DIR} + --check-dir ${RUNTIME_BUILTIN_JS_DIR} + ${RUNTIME_BUILTIN_JS} + DEPENDS ${RUNTIME_BUILTIN_JS} ${RUNTIME_BUILTINS_JS2C} + COMMENT "Generating RuntimeBuiltins from src/main/cpp/js" + VERBATIM +) + if (OPTIMIZED_BUILD OR OPTIMIZED_WITH_INSPECTOR_BUILD) set(CMAKE_CXX_FLAGS "${COMMON_CMAKE_ARGUMENTS} -O3 -fvisibility=hidden -ffunction-sections -fno-data-sections") else () @@ -99,6 +134,7 @@ add_library( src/main/cpp/ArrayElementAccessor.cpp src/main/cpp/ArrayHelper.cpp src/main/cpp/AssetExtractor.cpp + src/main/cpp/BuiltinLoader.cpp src/main/cpp/CallbackHandlers.cpp src/main/cpp/ConcurrentQueue.cpp src/main/cpp/Constants.cpp @@ -158,6 +194,8 @@ add_library( src/main/cpp/HMRSupport.cpp src/main/cpp/DevFlags.cpp + ${RUNTIME_BUILTINS_GENERATED_DIR}/RuntimeBuiltins.cpp + # V8 inspector source files will be included only in Release mode ${INSPECTOR_SOURCES} ) diff --git a/test-app/runtime/src/main/cpp/BuiltinLoader.cpp b/test-app/runtime/src/main/cpp/BuiltinLoader.cpp new file mode 100644 index 000000000..e9498555c --- /dev/null +++ b/test-app/runtime/src/main/cpp/BuiltinLoader.cpp @@ -0,0 +1,116 @@ +#include "BuiltinLoader.h" + +#include +#include + +#include "ArgConverter.h" + +using namespace v8; + +namespace tns { + +namespace { + +/* + * Process-wide bytecode cache shared across isolates. Worker runtimes + * initialize on their own threads, so every access is under the mutex. + */ +std::mutex builtinCacheMutex; +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. + */ +constexpr const char* kExportsParamName = "exports"; +constexpr const char* kModuleParamName = "module"; +constexpr const char* kBindingParamName = "binding"; +constexpr size_t kParamCount = 3; + +MaybeLocal CompileBuiltin(Local context, BuiltinId id) { + Isolate* isolate = v8::Isolate::GetCurrent(); + const BuiltinSource& builtin = GetBuiltinSource(id); + const unsigned index = static_cast(id); + + // Copy the blob out so the shared slot can be refreshed concurrently while + // this compile still reads from the copy. + std::vector blob; + { + std::lock_guard lock(builtinCacheMutex); + blob = builtinCache[index]; + } + + ScriptOrigin origin(ArgConverter::ConvertToV8String(isolate, builtin.name)); + Local sourceText = ArgConverter::ConvertToV8String( + isolate, builtin.source, static_cast(builtin.length)); + Local params[] = { + ArgConverter::ConvertToV8String(isolate, kExportsParamName), + ArgConverter::ConvertToV8String(isolate, kModuleParamName), + ArgConverter::ConvertToV8String(isolate, kBindingParamName)}; + + Local 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(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(); + } + + std::unique_ptr produced( + ScriptCompiler::CreateCodeCacheForFunction(fn)); + if (produced != nullptr && produced->data != nullptr && produced->length > 0) { + std::lock_guard lock(builtinCacheMutex); + builtinCache[index].assign(produced->data, produced->data + produced->length); + } + + return fn; +} + +} // namespace + +MaybeLocal BuiltinLoader::RunBuiltin(Local context, BuiltinId id, + Local binding) { + Isolate* isolate = v8::Isolate::GetCurrent(); + + Local fn; + if (!CompileBuiltin(context, id).ToLocal(&fn)) { + return MaybeLocal(); + } + + Local exportsObj = Object::New(isolate); + Local moduleObj = Object::New(isolate); + Local exportsKey = ArgConverter::ConvertToV8String(isolate, kExportsParamName); + if (!moduleObj->Set(context, exportsKey, exportsObj).FromMaybe(false)) { + return MaybeLocal(); + } + + Local args[] = {exportsObj, moduleObj, + binding.IsEmpty() ? Undefined(isolate).As() : binding}; + if (fn->Call(context, Undefined(isolate), static_cast(kParamCount), args).IsEmpty()) { + return MaybeLocal(); + } + + return moduleObj->Get(context, exportsKey); +} + +} // namespace tns diff --git a/test-app/runtime/src/main/cpp/BuiltinLoader.h b/test-app/runtime/src/main/cpp/BuiltinLoader.h new file mode 100644 index 000000000..944e07684 --- /dev/null +++ b/test-app/runtime/src/main/cpp/BuiltinLoader.h @@ -0,0 +1,29 @@ +#ifndef BUILTINLOADER_H_ +#define BUILTINLOADER_H_ + +#include "generated/RuntimeBuiltins.h" +#include "v8.h" + +namespace tns { + +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`. + * 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 + * populates the cache, later isolates (workers, which run on their own + * threads) consume it instead of re-parsing the source. + */ + static v8::MaybeLocal RunBuiltin( + v8::Local context, BuiltinId id, + v8::Local binding = v8::Local()); +}; + +} // namespace tns + +#endif /* BUILTINLOADER_H_ */ diff --git a/test-app/runtime/src/main/cpp/ErrorEvents.cpp b/test-app/runtime/src/main/cpp/ErrorEvents.cpp index 071c53b6e..b41ff1486 100644 --- a/test-app/runtime/src/main/cpp/ErrorEvents.cpp +++ b/test-app/runtime/src/main/cpp/ErrorEvents.cpp @@ -1,6 +1,7 @@ #include "ErrorEvents.h" #include "ArgConverter.h" +#include "BuiltinLoader.h" #include "NativeScriptAssert.h" #include "NativeScriptException.h" #include "Runtime.h" @@ -19,7 +20,7 @@ static Runtime* GetRuntimeOrNull(Isolate* isolate) { } /* - * Native function handed to the bootstrap IIFE as `nativeReportFatal(error, + * Native function handed to internal/error-events.js as `nativeReportFatal(error, * stackString)`. It runs the terminal tail (shim + log) WITHOUT re-dispatching * an event: reportError and listener-thrown errors have already gone through * JS dispatch, so dispatching again here would recurse. @@ -36,111 +37,6 @@ static void NativeReportFatalCallback(const FunctionCallbackInfo& info) { } void ErrorEvents::Init(Local context) { - /* - * WHATWG error-events layer, layered on top of the generic event - * primitives installed by Events::Init and ported from the iOS runtime. - * Plain (module-free) script, strict inside the IIFE, ES5-ish so it never - * depends on other runtime extensions. The IIFE is invoked with two - * arguments - the internal EventTarget backing the global (so native - * dispatch survives app code overwriting globalThis.dispatchEvent) and - * the native nativeReportFatal(error, stack) function that runs the - * terminal tail - and returns three closures bound to that backing store. - * ErrorEvent/PromiseRejectionEvent subclass the Event captured off - * globalThis at init time, which runs before any user code. - */ - auto source = R"js( - (function (globalTarget, nativeReportFatal) { - "use strict"; - var g = globalThis; - var Event = g.Event; - - function ErrorEvent(type, opts) { - opts = opts || {}; - Event.call(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.constructor = ErrorEvent; - - function PromiseRejectionEvent(type, opts) { - opts = opts || {}; - Event.call(this, type, opts); - this.promise = opts.promise; - this.reason = opts.reason; - } - PromiseRejectionEvent.prototype = Object.create(Event.prototype); - PromiseRejectionEvent.prototype.constructor = PromiseRejectionEvent; - - // A listener that throws must not stop other listeners: route the thrown - // value to the native fatal tail instead of ever recursively dispatching - // another `error` event from inside dispatch. - globalTarget._installListenerErrorReporter(function (e) { - try { nativeReportFatal(e, (e && e.stack) || ""); } catch (ignored) {} - }); - - g.reportError = function (e) { - if (arguments.length === 0) { - throw new TypeError("Failed to execute 'reportError': 1 argument required, but only 0 present."); - } - var ev = new ErrorEvent("error", { - message: (e && e.message !== undefined && e.message !== null) ? String(e.message) : String(e), - error: e, - cancelable: true - }); - if (globalTarget.dispatchEvent(ev)) { - nativeReportFatal(e, (e && e.stack) || ""); - } - }; - - g.ErrorEvent = ErrorEvent; - g.PromiseRejectionEvent = PromiseRejectionEvent; - - // Closures called by C++. They never look up globalThis.dispatchEvent, - // so they keep working even if app code overwrites it. - function dispatchErrorEvent(error, message, stack) { - var ev = new ErrorEvent("error", { - message: message !== undefined && message !== null ? String(message) : "", - error: error, - cancelable: true - }); - globalTarget.dispatchEvent(ev); - return ev.defaultPrevented; - } - function dispatchUnhandledRejection(promise, reason) { - var ev = new PromiseRejectionEvent("unhandledrejection", { - promise: promise, - reason: reason, - cancelable: true - }); - globalTarget.dispatchEvent(ev); - return ev.defaultPrevented; - } - function dispatchRejectionHandled(promise, reason) { - var ev = new PromiseRejectionEvent("rejectionhandled", { - promise: promise, - reason: reason, - cancelable: false - }); - globalTarget.dispatchEvent(ev); - } - function dispatchNativeUncaughtError(error, message, stack) { - var ev = new ErrorEvent("nativeuncaughterror", { - message: message !== undefined && message !== null ? String(message) : "", - error: error, - cancelable: true - }); - globalTarget.dispatchEvent(ev); - return ev.defaultPrevented; - } - - return [dispatchErrorEvent, dispatchUnhandledRejection, dispatchRejectionHandled, dispatchNativeUncaughtError]; - }) - )js"; - auto isolate = v8::Isolate::GetCurrent(); auto runtime = GetRuntimeOrNull(isolate); if (runtime == nullptr) { @@ -149,34 +45,25 @@ void ErrorEvents::Init(Local context) { if (runtime->GlobalEventTarget().IsEmpty()) { throw NativeScriptException("ErrorEvents::Init: Events::Init must run first"); } - Local globalTarget = runtime->GlobalEventTarget().Get(isolate); - - Local