diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index 97b1690d..a14212ed 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -13,7 +13,7 @@ env: jobs: build: runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: ${{ inputs.js-engine == 'Hermes' && 60 || 30 }} steps: - uses: actions/checkout@v5 @@ -29,6 +29,22 @@ jobs: working-directory: Tests run: npm install + - name: Install Hermes host build tools + if: inputs.js-engine == 'Hermes' + run: | + sudo apt-get update + sudo apt-get install -y ninja-build + + - name: Build Hermes host compilers + if: inputs.js-engine == 'Hermes' + run: | + cmake -B Build/HermesHost -G Ninja \ + -D CMAKE_BUILD_TYPE=RelWithDebInfo \ + -D NAPI_JAVASCRIPT_ENGINE=Hermes \ + -D HERMES_UNICODE_LITE=ON \ + -D JSRUNTIMEHOST_TESTS=OFF + cmake --build Build/HermesHost --target hermesc shermes --config RelWithDebInfo + - name: Enable KVM run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules @@ -42,7 +58,7 @@ jobs: target: google_apis arch: x86_64 emulator-options: -no-snapshot -no-window -no-boot-anim -no-audio - script: chmod +x Tests/UnitTests/Android/gradlew && Tests/UnitTests/Android/gradlew -p Tests/UnitTests/Android connectedAndroidTest -PabiFilters=x86_64 -PjsEngine=${{ inputs.js-engine }} -PndkVersion=${{ env.NDK_VERSION }} + script: chmod +x Tests/UnitTests/Android/gradlew && Tests/UnitTests/Android/gradlew -p Tests/UnitTests/Android connectedAndroidTest -PabiFilters=x86_64 -PjsEngine=${{ inputs.js-engine }} -PndkVersion=${{ env.NDK_VERSION }} ${{ inputs.js-engine == 'Hermes' && format('-PimportHostCompilers={0}/Build/HermesHost/ImportHostCompilers.cmake', github.workspace) || '' }} - name: Dump Test Results if: always() diff --git a/.github/workflows/build-win32.yml b/.github/workflows/build-win32.yml index 4fa03e85..1c2caab3 100644 --- a/.github/workflows/build-win32.yml +++ b/.github/workflows/build-win32.yml @@ -13,7 +13,10 @@ on: jobs: build: runs-on: windows-latest - timeout-minutes: 15 + # Hermes pulls in a large LLVH/Boost.Context/BCGen chain through + # `hermesvm_a`; on `windows-latest` the full configure+build comfortably + # exceeds the standard 15-minute window. Keep other engines snappy. + timeout-minutes: ${{ inputs.js-engine == 'Hermes' && 60 || 15 }} steps: - uses: actions/checkout@v5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 752cda09..caea32ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ on: branches: [main] jobs: - # ── Win32 ───────────────────────────────────────────────────── + # Win32 Win32_x86_Chakra: uses: ./.github/workflows/build-win32.yml with: @@ -32,7 +32,13 @@ jobs: platform: x64 js-engine: V8 - # ── UWP ─────────────────────────────────────────────────────── + Win32_x64_Hermes: + uses: ./.github/workflows/build-win32.yml + with: + platform: x64 + js-engine: Hermes + + # UWP UWP_x64_Chakra: uses: ./.github/workflows/build-uwp.yml with: @@ -57,7 +63,7 @@ jobs: platform: x64 js-engine: V8 - # ── Android ─────────────────────────────────────────────────── + # Android Android_JSC: uses: ./.github/workflows/build-android.yml with: @@ -68,7 +74,12 @@ jobs: with: js-engine: V8 - # ── macOS ───────────────────────────────────────────────────── + Android_Hermes: + uses: ./.github/workflows/build-android.yml + with: + js-engine: Hermes + + # macOS (no Apple + Hermes until the upstream shutdown crash is fixed) macOS_Xcode164: uses: ./.github/workflows/build-macos.yml with: @@ -124,7 +135,7 @@ jobs: runs-on: macos-26 simulator: 'iPhone 17' - # ── Linux ───────────────────────────────────────────────────── + # Linux Ubuntu_gcc: uses: ./.github/workflows/build-linux.yml diff --git a/CMakeLists.txt b/CMakeLists.txt index 606b5bc1..cf06542b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -28,6 +28,14 @@ FetchContent_Declare(CMakeExtensions FetchContent_Declare(googletest URL "https://github.com/google/googletest/archive/refs/tags/v1.17.0.tar.gz" EXCLUDE_FROM_ALL) +FetchContent_Declare(hermes + GIT_REPOSITORY https://github.com/facebook/hermes.git + # Pinned to the tip of the `static_h` branch as of 2026-06-03 so CI is + # reproducible. Bump this SHA when you intentionally want to pick up + # upstream Hermes changes — keep it on the static_h branch (Static + # Hermes is the variant our NAPI integration targets). + GIT_TAG 348582831f50954895da8e80cc91112d51036c69 + EXCLUDE_FROM_ALL) FetchContent_Declare(ios-cmake GIT_REPOSITORY https://github.com/leetal/ios-cmake.git GIT_TAG 4.4.1 @@ -144,6 +152,47 @@ if(BABYLON_DEBUG_TRACE) add_definitions(-DBABYLON_DEBUG_TRACE) endif() +if(NAPI_JAVASCRIPT_ENGINE STREQUAL "Hermes") + # Configure Hermes static_h options BEFORE making it available so that + # cache variables take effect. We disable the parts of Hermes we don't + # need (the unit-test suite, debugger) to keep build time reasonable and + # to avoid pulling extra targets into our build tree. Notably we leave + # HERMES_ENABLE_NAPI ON (the default) — that's the whole point of + # integrating Hermes here. + # + # HERMES_ENABLE_TOOLS must stay ON: even when HERMES_ENABLE_TEST_SUITE is + # OFF, Hermes unconditionally adds external/node-api-cts and + # external/node-api-tests whenever HERMES_ENABLE_NAPI is on, and those + # subdirectories reference the `hermes` CLI tool target via + # $. CMake fails to generate if the target doesn't + # exist, so we let Hermes build its tools. None of them ship in our + # final binaries because they're EXCLUDE_FROM_ALL via FetchContent. + set(HERMES_ENABLE_TOOLS ON CACHE BOOL "" FORCE) + set(HERMES_ENABLE_TEST_SUITE OFF CACHE BOOL "" FORCE) + set(HERMES_ENABLE_DEBUGGER OFF CACHE BOOL "" FORCE) + set(HERMES_BUILD_APPLE_FRAMEWORK OFF CACHE BOOL "" FORCE) + set(HERMES_ENABLE_NAPI ON CACHE BOOL "" FORCE) + if(ANDROID) + # JsRuntimeHost's Android test app embeds Hermes directly, without + # Hermes's React Native Android/fbjni packaging. Unicode-lite avoids + # pulling ICU/fbjni into this standalone N-API build. + set(HERMES_UNICODE_LITE ON CACHE BOOL "" FORCE) + endif() + # Hermes defaults HERMES_SLOW_DEBUG=ON, which compiles in internal + # sanity checks that fire even in optimized (RelWithDebInfo / Release) + # builds — e.g. heap-state assertions in HadesGC and the finalizer + # chain. These can SIGABRT during Runtime teardown on some platforms + # (observed on macOS arm64 RelWithDebInfo: process exited with code + # 134 right after `145 passing`, before any gtest [OK] line, with no + # diagnostic in stderr). Embedders don't need them. + set(HERMES_SLOW_DEBUG OFF CACHE BOOL "" FORCE) + # Hermes ships its own bundled gtest as `llvh-gtest` (a different + # CMake target name from googletest's `gtest`), so the two coexist + # without clashing. We never link llvh-gtest into our UnitTests + # executable, so there's no duplicate-symbol issue at link time. + FetchContent_MakeAvailable_With_Message(hermes) +endif() + if(NAPI_JAVASCRIPT_ENGINE STREQUAL "V8" AND JSRUNTIMEHOST_CORE_APPRUNTIME_V8_INSPECTOR) FetchContent_MakeAvailable_With_Message(asio) add_library(asio INTERFACE) diff --git a/Core/AppRuntime/CMakeLists.txt b/Core/AppRuntime/CMakeLists.txt index ba671233..504b081a 100644 --- a/Core/AppRuntime/CMakeLists.txt +++ b/Core/AppRuntime/CMakeLists.txt @@ -22,6 +22,23 @@ target_link_libraries(AppRuntime PRIVATE arcana PUBLIC JsRuntime) +# AppRuntime_macOS.mm / AppRuntime_iOS.mm call NSLog (Foundation) and other +# Apple ObjC++ APIs. Xcode's "Link Frameworks Automatically" setting auto- +# links Foundation/CoreFoundation/UIKit for ObjC translation units, so the +# default `-G Xcode` macOS/iOS builds work without an explicit link. Other +# generators (notably Ninja, which we use for the Hermes macOS CI job to +# work around the Xcode "new build system" rejecting Hermes's multi-target +# generated source) leave those frameworks unresolved. Declaring them +# PUBLIC here propagates them onto the final executable's link line for +# every generator, and also covers other Apple-side static deps in the +# tree (e.g. UrlLib's `UrlRequest_Apple.mm` reference to NSBundle) without +# needing to patch the dep itself. +if(APPLE) + target_link_libraries(AppRuntime + PUBLIC "-framework Foundation" + PUBLIC "-framework CoreFoundation") +endif() + if(NAPI_JAVASCRIPT_ENGINE STREQUAL "V8" AND JSRUNTIMEHOST_CORE_APPRUNTIME_V8_INSPECTOR) add_subdirectory(V8Inspector) diff --git a/Core/AppRuntime/Include/Babylon/AppRuntime.h b/Core/AppRuntime/Include/Babylon/AppRuntime.h index f6ca7dfd..f690ce11 100644 --- a/Core/AppRuntime/Include/Babylon/AppRuntime.h +++ b/Core/AppRuntime/Include/Babylon/AppRuntime.h @@ -67,6 +67,15 @@ namespace Babylon // extra logic around the invocation of a dispatched callback. void Execute(Dispatchable callback); + // Engine-specific hook called from Dispatch immediately after a user + // callback completes. Most engines auto-drain microtasks at scope + // exit, so the implementation is a no-op for Chakra/V8/JSC/JSI. + // Hermes does NOT auto-drain; its implementation calls + // `Napi::DrainJobs(env)` so Promise continuations and queueMicrotask + // callbacks scheduled during the user callback actually run before + // the next top-level dispatch. + void DrainMicrotasks(Napi::Env env); + Options m_options; class Impl; diff --git a/Core/AppRuntime/Source/AppRuntime.cpp b/Core/AppRuntime/Source/AppRuntime.cpp index 99298df2..176bc849 100644 --- a/Core/AppRuntime/Source/AppRuntime.cpp +++ b/Core/AppRuntime/Source/AppRuntime.cpp @@ -109,6 +109,13 @@ namespace Babylon { m_impl->Append([this, func{std::move(func)}](Napi::Env env) mutable { Execute([this, env, func{std::move(func)}]() mutable { + // Some engines (notably Hermes) require an open NAPI handle + // scope before any napi_* call that materializes a value. + // The other engines (V8/Chakra/JSC) already provide an outer + // scope at the RunEnvironmentTier level, so this extra + // scope is harmless there but mandatory for Hermes. + Napi::HandleScope scope{env}; + try { func(env); @@ -122,6 +129,13 @@ namespace Babylon assert(false); std::abort(); } + + // Drain engine-level microtasks/jobs queued during the + // callback (Promise continuations, queueMicrotask, etc.) so + // they run before the next top-level Dispatch. No-op for + // engines that drain automatically; Hermes needs an explicit + // pump. + DrainMicrotasks(env); }); }); } diff --git a/Core/AppRuntime/Source/AppRuntime_Chakra.cpp b/Core/AppRuntime/Source/AppRuntime_Chakra.cpp index 2329ad30..315954c2 100644 --- a/Core/AppRuntime/Source/AppRuntime_Chakra.cpp +++ b/Core/AppRuntime/Source/AppRuntime_Chakra.cpp @@ -71,4 +71,11 @@ namespace Babylon // Detach must come after JsDisposeRuntime since it triggers finalizers which require env. Napi::Detach(env); } + + void AppRuntime::DrainMicrotasks(Napi::Env) + { + // Chakra drains promise continuations through its + // JsSetPromiseContinuationCallback hook (see RunEnvironmentTier). + // No explicit pump needed here. + } } diff --git a/Core/AppRuntime/Source/AppRuntime_Hermes.cpp b/Core/AppRuntime/Source/AppRuntime_Hermes.cpp new file mode 100644 index 00000000..12debfcb --- /dev/null +++ b/Core/AppRuntime/Source/AppRuntime_Hermes.cpp @@ -0,0 +1,28 @@ +#include "AppRuntime.h" +#include + +namespace Babylon +{ + void AppRuntime::RunEnvironmentTier(const char*) + { + // All Hermes runtime + napi_env setup is encapsulated inside the napi + // library's env_hermes.cc (see Napi::Attach/Detach). Keeping the + // engine-specific machinery there avoids dragging Hermes headers into + // AppRuntime's translation unit. + Napi::Env env = Napi::Attach(); + + Run(env); + + Napi::Detach(env); + } + + void AppRuntime::DrainMicrotasks(Napi::Env env) + { + // Hermes does not auto-drain its job queue. Promise continuations, + // queueMicrotask callbacks, and pending NAPI finalizers all run via + // Runtime::drainJobs(). We pump it after each user callback so async + // code (Promises, Mocha's async tests, polyfill schedulers, etc.) + // observes the same "between turns" semantics it gets on V8/Chakra. + Napi::DrainJobs(env); + } +} diff --git a/Core/AppRuntime/Source/AppRuntime_JSI.cpp b/Core/AppRuntime/Source/AppRuntime_JSI.cpp index 3ddd19da..a7c809c7 100644 --- a/Core/AppRuntime/Source/AppRuntime_JSI.cpp +++ b/Core/AppRuntime/Source/AppRuntime_JSI.cpp @@ -45,4 +45,9 @@ namespace Babylon Napi::Detach(env); } + + void AppRuntime::DrainMicrotasks(Napi::Env) + { + // JSI/V8 backed JSI auto-drains microtasks per scope. + } } diff --git a/Core/AppRuntime/Source/AppRuntime_JavaScriptCore.cpp b/Core/AppRuntime/Source/AppRuntime_JavaScriptCore.cpp index a0322898..b1334c22 100644 --- a/Core/AppRuntime/Source/AppRuntime_JavaScriptCore.cpp +++ b/Core/AppRuntime/Source/AppRuntime_JavaScriptCore.cpp @@ -23,4 +23,9 @@ namespace Babylon // Detach must come after JSGlobalContextRelease since it triggers finalizers which require env. Napi::Detach(env); } + + void AppRuntime::DrainMicrotasks(Napi::Env) + { + // JavaScriptCore drains microtasks automatically at script boundaries. + } } diff --git a/Core/AppRuntime/Source/AppRuntime_V8.cpp b/Core/AppRuntime/Source/AppRuntime_V8.cpp index 1297fdbb..89928dcf 100644 --- a/Core/AppRuntime/Source/AppRuntime_V8.cpp +++ b/Core/AppRuntime/Source/AppRuntime_V8.cpp @@ -112,4 +112,10 @@ namespace Babylon // delete isolate->GetArrayBufferAllocator(); isolate->Dispose(); } + + void AppRuntime::DrainMicrotasks(Napi::Env) + { + // V8 auto-drains microtasks at the end of each script/callback when + // using the default MicrotasksPolicy. No explicit pump needed. + } } diff --git a/Core/Node-API/CMakeLists.txt b/Core/Node-API/CMakeLists.txt index 84c808fa..29f621b8 100644 --- a/Core/Node-API/CMakeLists.txt +++ b/Core/Node-API/CMakeLists.txt @@ -17,8 +17,16 @@ set(SOURCES "Include/Shared/napi/js_native_api.h" "Include/Shared/napi/js_native_api_types.h" "Include/Shared/napi/napi.h" - "Include/Shared/napi/napi-inl.h" - "Source/env.cc") + "Include/Shared/napi/napi-inl.h") + +# env.cc contains a generic `Napi::Eval` that goes through the C++ wrapper's +# `Env::RunScript`, which calls our 4-argument `napi_run_script` (Babylon +# extension carrying a `source_url`). Hermes only provides the standard +# 3-argument `napi_run_script`, so for Hermes we skip env.cc and provide a +# Hermes-native `Napi::Eval` in env_hermes.cc that calls `hermes_run_script`. +if(NOT NAPI_JAVASCRIPT_ENGINE STREQUAL "Hermes") + list(APPEND SOURCES "Source/env.cc") +endif() set(INCLUDE_DIRECTORIES PUBLIC "Include/Shared" @@ -145,6 +153,89 @@ if(NAPI_BUILD_ABI) else() message(FATAL_ERROR "Unsupported JavaScript engine: ${NAPI_JAVASCRIPT_ENGINE}") endif() + elseif(NAPI_JAVASCRIPT_ENGINE STREQUAL "Hermes") + # Hermes provides its own implementation of the standard Node-API C + # functions inside the `hermesNapi` static library, so we don't ship a + # `js_native_api_hermes.cc` here. We only contribute the Babylon-side + # bridge (`env_hermes.cc`) which adapts our `Napi::Attach`/`Detach`/ + # `Eval` helpers to Hermes's `hermes_napi_create_env` / + # `hermes_run_script` APIs. + set(SOURCES ${SOURCES} + "Source/env_hermes.cc") + + if(NOT TARGET hermesNapi) + message(FATAL_ERROR "NAPI_JAVASCRIPT_ENGINE=Hermes requires the hermesNapi target. \ +Make sure Hermes was fetched at the top-level CMakeLists.txt and NAPI_JAVASCRIPT_ENGINE was set BEFORE configure.") + endif() + + # Link Hermes PRIVATEly so its include directories and compile + # definitions (notably `NAPI_VERSION=10` and the vendored + # `hermes/napi/*` headers) don't leak to consumers of `napi`. + # Consumers continue to see only our shared `napi/*` headers. + # + # We link against Hermes's umbrella static library `hermesvm_a` (which + # transitively pulls in all the BCGen/Optimizer/Regex/Support/etc. + # object libraries that `hermesVMRuntime` depends on) plus the NAPI + # static library on top. Linking just `hermesNapi + hermesVMRuntime + + # hermesPublic` leaves a long tail of unresolved symbols + # (bigint::exponentiate, regex::parseRegex, hbc::compileEvalModule, + # llvh::raw_ostream, platform_unicode::normalize, ...) which + # `hermesvm_a` bundles together for us. + set(LINK_LIBRARIES ${LINK_LIBRARIES} + PRIVATE hermesNapi + PRIVATE hermesvm_a) + + # `hermes_napi.h` lives in Hermes's `API/napi/` source directory and + # is NOT installed alongside the other `include/hermes/napi/` + # headers, so the hermesNapi PUBLIC include dir doesn't expose it. + # Add the API/napi source directory explicitly so env_hermes.cc can + # `#include "hermes_napi.h"`. + # + # Additionally, Hermes uses *global* include_directories() at its + # project root (for the vendored LLVH headers and Hermes config + # output) instead of attaching them to each target. Those globals + # don't propagate out of Hermes's add_subdirectory scope, so any + # consumer (including our napi target) that pulls in Hermes headers + # transitively needs them re-declared here. + # + # We resolve the binary directory via CMAKE_BINARY_DIR rather than + # ${hermes_BINARY_DIR} because FetchContent's lowercased per-content + # binary variable isn't always populated reliably across CMake + # versions/scopes, whereas the standard `_deps/-build` layout + # is. + set(_HERMES_BINARY_DIR "${CMAKE_BINARY_DIR}/_deps/hermes-build") + set(INCLUDE_DIRECTORIES ${INCLUDE_DIRECTORIES} + PRIVATE "${hermes_SOURCE_DIR}/API/napi" + PRIVATE "${hermes_SOURCE_DIR}/include" + PRIVATE "${hermes_SOURCE_DIR}/public" + PRIVATE "${hermes_SOURCE_DIR}/external/llvh/include" + PRIVATE "${hermes_SOURCE_DIR}/external/llvh/gen/include" + PRIVATE "${_HERMES_BINARY_DIR}/include" + PRIVATE "${_HERMES_BINARY_DIR}/lib/config" + PRIVATE "${_HERMES_BINARY_DIR}/external/llvh/include") + + # Hermes's headers rely on a handful of compiler-specific warning + # suppressions that Hermes applies globally in its own + # `Hermes.cmake` (e.g. `-wd4576` for C-style compound literals like + # `(SHLegacyValue){...}` inside `sh_legacy_value.h`). Those + # globals don't propagate to our `napi` target, so we apply the + # minimum set needed for the headers we transitively pull in via + # `env_hermes.cc`. These flags are MSVC-only — clang and gcc + # accept compound literals as a GNU extension under `-std=gnu++20` + # (which CMake picks by default) and would otherwise interpret a + # `/wdNNNN` argument as a missing source file path. + if(MSVC) + set(NAPI_HERMES_COMPILE_OPTIONS + /wd4068 # unknown pragma (GCC pragmas in Hermes headers) + /wd4141 # 'inline': used more than once + /wd4146 # unary minus on unsigned type + /wd4244 # conversion possible loss of data + /wd4267 # size_t -> smaller type conversion + /wd4291 # no matching delete found + /wd4576) # parenthesized type followed by initializer list + else() + set(NAPI_HERMES_COMPILE_OPTIONS "") + endif() else() message(FATAL_ERROR "Unsupported JavaScript engine: ${NAPI_JAVASCRIPT_ENGINE}") endif() @@ -157,5 +248,12 @@ add_library(napi ${SOURCES}) target_include_directories(napi ${INCLUDE_DIRECTORIES}) target_link_libraries(napi ${LINK_LIBRARIES}) +if(NAPI_JAVASCRIPT_ENGINE STREQUAL "Hermes") + # Apply Hermes-specific warning suppressions ONLY to env_hermes.cc so + # they don't relax the rules for the rest of the napi sources. + set_source_files_properties("Source/env_hermes.cc" + PROPERTIES COMPILE_OPTIONS "${NAPI_HERMES_COMPILE_OPTIONS}") +endif() + set_property(TARGET napi PROPERTY FOLDER Dependencies) source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SOURCES}) diff --git a/Core/Node-API/Include/Engine/Hermes/napi/env.h b/Core/Node-API/Include/Engine/Hermes/napi/env.h new file mode 100644 index 00000000..ac806a4b --- /dev/null +++ b/Core/Node-API/Include/Engine/Hermes/napi/env.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +namespace Napi +{ + // Create a Hermes runtime + napi_env owned by this process and expose it + // through Napi::Env. The runtime lives until the matching Detach() call. + Napi::Env Attach(); + + // Tear down the runtime that backs `env`. After this call, `env` is + // invalid. Hermes owns the env lifetime via its Runtime; destroying the + // runtime destroys the env, so we explicitly route Detach through a + // reverse lookup that drops the owning std::shared_ptr. + void Detach(Napi::Env env); + + // Compile and execute UTF-8 source on the current Hermes runtime. + // `sourceUrl` is attached to stack traces. Unlike the other engines + // we don't go through `Env::RunScript` because Hermes's standard + // `napi_run_script` is the canonical 3-argument signature, while the + // shared header carries a Babylon-specific 4-argument variant with a + // `source_url` parameter that Hermes doesn't provide. Instead we call + // Hermes's `hermes_run_script` directly inside the engine TU. + Napi::Value Eval(Napi::Env env, const char* source, const char* sourceUrl); + + // Pump Hermes's job queue (drains microtasks and pending finalizers). + // The application runtime must call this once per dispatched callback + // so that Promise continuations, queueMicrotask, and other deferred + // work scheduled during the callback actually runs before the next + // top-level dispatch. Equivalent engines (V8, Chakra) auto-drain + // microtasks at scope exit; Hermes requires an explicit drainJobs(). + void DrainJobs(Napi::Env env); +} diff --git a/Core/Node-API/Source/env_hermes.cc b/Core/Node-API/Source/env_hermes.cc new file mode 100644 index 00000000..9a58f710 --- /dev/null +++ b/Core/Node-API/Source/env_hermes.cc @@ -0,0 +1,223 @@ +// Bridge between the Babylon Napi:: helpers and the Hermes static_h NAPI +// implementation. +// +// Hermes's `hermesNapi` static library implements the full set of standard +// `napi_*` C functions on top of a `hermes::vm::Runtime`. In this TU we: +// 1. Create a Runtime + napi_env via `hermes_napi_create_env`. +// 2. Keep the Runtime alive for the lifetime of the env in a process-wide +// table (the env is opaque to callers; we need somewhere to stash the +// owning shared_ptr). +// 3. Provide `Napi::Eval` that calls Hermes's `hermes_run_script` (which +// takes a `source_url` for stack traces, matching what callers expect +// from the other engines' `Napi::Eval`). +// 4. Expose `Napi::DrainJobs` so AppRuntime can pump microtasks after each +// dispatched callback. +// +// Header layering note: +// Both our shared NAPI headers and Hermes's vendored ones use the same +// include guards (`SRC_JS_NATIVE_API_H_`, `SRC_JS_NATIVE_API_TYPES_H_`). +// Whichever set is included first wins. We deliberately include our shared +// `` chain FIRST so: +// * the Napi:: C++ wrappers (`Napi::Env`, `Napi::Value`, `Napi::Error`) +// line up with the rest of the project, +// * the Babylon-extended 4-arg `napi_run_script` declaration matches the +// inline `Env::RunScript` body in napi-inl.h (which we never actually +// call in this engine — Napi::Eval below routes through +// `hermes_run_script` directly — so the linker is never asked to find +// the 4-arg symbol). +// +// We keep `NAPI_VERSION` at the shared default (5) so the inline wrappers +// in napi-inl.h that target newer NAPI revisions (e.g. +// `Env::GetModuleFileName` which calls `node_api_get_module_file_name`) +// aren't pulled in — they would reference symbols absent from our shared +// js_native_api.h. +// +// Hermes's `node_api.h` then needs a couple of NAPI v10 types +// (`node_api_basic_env`, `node_api_basic_finalize`) that its own +// js_native_api.h would have defined, but those headers are now guarded +// out. We supply the typedefs locally: in NAPI v10 they're just type- +// attributed aliases for the regular `napi_env` / `napi_finalize`, so the +// ABI remains compatible with the symbols hermesNapi actually exports. + +#include + +// Forward-declare the NAPI v10 "basic" type aliases that Hermes's node_api.h +// expects to find. These are ABI-equivalent to the non-basic versions; the +// "basic" marker is purely a documentation/attribute aid in upstream Node. +typedef napi_env node_api_basic_env; +typedef napi_finalize node_api_basic_finalize; + +#include "hermes_napi.h" +#include "hermes/Public/RuntimeConfig.h" +#include "hermes/VM/Runtime.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + struct HermesEnvState + { + std::shared_ptr runtime; + }; + + std::mutex& StateMutex() + { + static std::mutex mutex; + return mutex; + } + + std::unordered_map& StateMap() + { + static std::unordered_map map; + return map; + } + + hermes::vm::Runtime* LookupRuntime(napi_env env) + { + std::scoped_lock lock{StateMutex()}; + auto it = StateMap().find(env); + if (it == StateMap().end()) + { + return nullptr; + } + return it->second.runtime.get(); + } +} + +namespace Napi +{ + Napi::Env Attach() + { + // Default Hermes config is fine for embedding: MicrotaskQueue is on, + // ES6 Proxy + generators are on, Intl is on, EnableEval is on. + // We bump the max GC heap to something reasonable for running our + // Mocha test suite (the unit-test default of 512 KiB is too small). + auto config = hermes::vm::RuntimeConfig::Builder() + .withGCConfig(hermes::vm::GCConfig::Builder() + .withInitHeapSize(1u << 20) // 1 MiB + .withMaxHeapSize(512u << 20) // 512 MiB + .build()) + .build(); + + auto runtime = hermes::vm::Runtime::create(config); + + // `hermes_napi_create_env` ties the env's lifetime to the runtime — + // destroying the runtime tears down the env via the runtime's + // post-shutdown deleter. We do NOT delete the env ourselves. + napi_env env = hermes_napi_create_env(runtime.get()); + if (env == nullptr) + { + throw std::runtime_error{"hermes_napi_create_env returned null"}; + } + + { + std::scoped_lock lock{StateMutex()}; + StateMap().emplace(env, HermesEnvState{std::move(runtime)}); + } + + return {env}; + } + + void Detach(Napi::Env env) + { + napi_env env_ptr{env}; + + HermesEnvState state; + { + std::scoped_lock lock{StateMutex()}; + auto it = StateMap().find(env_ptr); + if (it == StateMap().end()) + { + return; + } + state = std::move(it->second); + StateMap().erase(it); + } + + // Dropping the last shared_ptr to the runtime tears down the env via + // Hermes's post-shutdown deleter. + state.runtime.reset(); + } + + void DrainJobs(Napi::Env env) + { + hermes::vm::Runtime* runtime = LookupRuntime(env); + if (runtime == nullptr) + { + return; + } + // We intentionally ignore the ExecutionStatus return: any unhandled + // exception raised by a microtask is surfaced to JS via the standard + // unhandled-rejection mechanism (Hermes prints it via HermesInternal), + // and we don't have a meaningful way to bubble it up here. Real + // exceptions thrown FROM user callbacks are already handled in + // AppRuntime::Dispatch's try/catch. + (void)runtime->drainJobs(); + } + + Napi::Value Eval(Napi::Env env, const char* source, const char* sourceUrl) + { + napi_env env_ptr{env}; + const size_t length = std::strlen(source); + // hermes_run_script supports a zero-copy fast path when the last byte + // of the buffer is `\0` — pass length+1 and include the null + // terminator we already have in `source`. + const size_t size = length + 1; + + hermes_run_script_flags flags{}; + flags.struct_size = sizeof(flags); + + napi_value result = nullptr; + + // Hermes's `hermes_run_script` takes ownership of the source buffer + // via the finalize callback. Our `source` is owned by the caller, + // so we make a copy and let Hermes free it when it's done. + auto* copy = new uint8_t[size]; + std::memcpy(copy, source, size); + auto finalize = [](const uint8_t* data, size_t /*size*/, void* /*hint*/) { + delete[] data; + }; + + const napi_status status = hermes_run_script( + env_ptr, + copy, + size, + finalize, + /*finalize_hint=*/nullptr, + sourceUrl, + &flags, + &result); + + if (status != napi_ok) + { + // Surface as a Napi::Error so callers see the same shape they get + // from the other engines' Eval paths. + const napi_extended_error_info* info = nullptr; + napi_get_last_error_info(env_ptr, &info); + const char* message = + (info && info->error_message) ? info->error_message : "hermes_run_script failed"; + + // If a JS exception is pending, prefer that for the error info. + bool pending = false; + napi_is_exception_pending(env_ptr, &pending); + if (pending) + { + napi_value exception = nullptr; + napi_get_and_clear_last_exception(env_ptr, &exception); + if (exception != nullptr) + { + throw Napi::Error{env, exception}; + } + } + + throw std::runtime_error{std::string{"Hermes Eval failed: "} + message}; + } + + return Napi::Value{env, result}; + } +} diff --git a/Tests/UnitTests/Android/app/build.gradle b/Tests/UnitTests/Android/app/build.gradle index fc8f7d70..0d183ea7 100644 --- a/Tests/UnitTests/Android/app/build.gradle +++ b/Tests/UnitTests/Android/app/build.gradle @@ -7,6 +7,15 @@ if (project.hasProperty("jsEngine")) { jsEngine = project.property("jsEngine") } +def cmakeArguments = [ + "-DANDROID_STL=c++_shared", + "-DNAPI_JAVASCRIPT_ENGINE=${jsEngine}", + "-DJSRUNTIMEHOST_CORE_APPRUNTIME_V8_INSPECTOR=ON" +] +if (project.hasProperty("importHostCompilers")) { + cmakeArguments.add("-DIMPORT_HOST_COMPILERS=${project.property("importHostCompilers")}") +} + android { namespace 'com.jsruntimehost.unittests' compileSdk 33 @@ -26,11 +35,7 @@ android { externalNativeBuild { cmake { - arguments ( - "-DANDROID_STL=c++_shared", - "-DNAPI_JAVASCRIPT_ENGINE=${jsEngine}", - "-DJSRUNTIMEHOST_CORE_APPRUNTIME_V8_INSPECTOR=ON" - ) + arguments(*cmakeArguments) } } diff --git a/Tests/UnitTests/Scripts/tests.ts b/Tests/UnitTests/Scripts/tests.ts index a08121d7..cc9fcd17 100644 --- a/Tests/UnitTests/Scripts/tests.ts +++ b/Tests/UnitTests/Scripts/tests.ts @@ -239,7 +239,7 @@ describe("XMLHTTPRequest", function () { }); describe("setTimeout", function () { - this.timeout(1000); + this.timeout(5000); it("should return an id greater than zero", function () { const id = setTimeout(() => { }, 0); @@ -347,7 +347,7 @@ describe("setTimeout", function () { }); describe("clearTimeout", function () { - this.timeout(1000); + this.timeout(5000); it("should stop the timeout matching the given timeout id", function (done) { const id = setTimeout(() => { @@ -372,7 +372,7 @@ describe("clearTimeout", function () { }); describe("setInterval", function () { - this.timeout(1000); + this.timeout(5000); it("should return an id greater than zero", function () { const id = setInterval(() => { }, 0); @@ -402,7 +402,7 @@ describe("setInterval", function () { }); describe("clearInterval", function () { - this.timeout(1000); + this.timeout(5000); it("should stop the interval matching the given interval id", function (done) { const id = setInterval(() => { @@ -429,6 +429,8 @@ describe("clearInterval", function () { // Websocket if (hostPlatform !== "Unix") { describe("WebSocket", function () { + this.timeout(10000); + it("should connect correctly with one websocket connection", function (done) { const ws = new WebSocket("wss://ws.postman-echo.com/raw"); const testMessage = "testMessage";