From 57e7b9f802e651fe6f176c11f39e20cd1f3d42e5 Mon Sep 17 00:00:00 2001 From: Joyee Cheung Date: Sun, 8 Mar 2026 22:37:20 +0100 Subject: [PATCH] sea: support code cache for ESM entrypoint in SEA The initial support for ESM entrypoint in SEA didn't support code cache. This patch implements that by following a path similar to how code cache in CJS SEA entrypoint is supported: at build time we generate the code cache from C++ and put it into the sea blob, and at runtime we consume it via a special case in compilation routines - for CJS this was CompileFunctionForCJSLoader, in the case of SourceTextModule, it's in Module::New. --- doc/api/single-executable-applications.md | 3 +- src/module_wrap.cc | 42 ++++++-- src/node_sea.cc | 98 ++++++++++++------- .../sea/esm-code-cache/sea-config.json | 7 ++ test/fixtures/sea/esm-code-cache/sea.mjs | 20 ++++ ...e-executable-application-esm-code-cache.js | 34 +++++++ 6 files changed, 159 insertions(+), 45 deletions(-) create mode 100644 test/fixtures/sea/esm-code-cache/sea-config.json create mode 100644 test/fixtures/sea/esm-code-cache/sea.mjs create mode 100644 test/sea/test-single-executable-application-esm-code-cache.js diff --git a/doc/api/single-executable-applications.md b/doc/api/single-executable-applications.md index 015e1d048bd268..c63b8c8f57c0c3 100644 --- a/doc/api/single-executable-applications.md +++ b/doc/api/single-executable-applications.md @@ -395,8 +395,7 @@ The accepted values are: If the `mainFormat` field is not specified, it defaults to `"commonjs"`. -Currently, `"mainFormat": "module"` cannot be used together with `"useSnapshot"` -or `"useCodeCache"`. +Currently, `"mainFormat": "module"` cannot be used together with `"useSnapshot"`. ### Module loading in the injected main script diff --git a/src/module_wrap.cc b/src/module_wrap.cc index 354b45bda9ccc7..ef69fe133fad61 100644 --- a/src/module_wrap.cc +++ b/src/module_wrap.cc @@ -1,5 +1,6 @@ #include "module_wrap.h" +#include "debug_utils-inl.h" #include "env.h" #include "memory_tracker-inl.h" #include "node_contextify.h" @@ -7,6 +8,7 @@ #include "node_external_reference.h" #include "node_internals.h" #include "node_process-inl.h" +#include "node_sea.h" #include "node_url.h" #include "node_watchdog.h" #include "util-inl.h" @@ -365,6 +367,20 @@ void ModuleWrap::New(const FunctionCallbackInfo& args) { new ScriptCompiler::CachedData(data + cached_data_buf->ByteOffset(), cached_data_buf->ByteLength()); } +#ifndef DISABLE_SINGLE_EXECUTABLE_APPLICATION + // For embedder ESM in a SEA, use the bundled code cache if available. + if (id_symbol == realm->isolate_data()->embedder_module_hdo() && + sea::IsSingleExecutable()) { + sea::SeaResource sea = sea::FindSingleExecutableResource(); + if (sea.use_code_cache()) { + std::string_view data = sea.code_cache.value(); + user_cached_data = new ScriptCompiler::CachedData( + reinterpret_cast(data.data()), + static_cast(data.size()), + ScriptCompiler::CachedData::BufferNotOwned); + } + } +#endif // !DISABLE_SINGLE_EXECUTABLE_APPLICATION Local source_text = args[2].As(); bool cache_rejected = false; @@ -389,12 +405,26 @@ void ModuleWrap::New(const FunctionCallbackInfo& args) { return; } - if (user_cached_data.has_value() && user_cached_data.value() != nullptr && - cache_rejected) { - THROW_ERR_VM_MODULE_CACHED_DATA_REJECTED( - realm, "cachedData buffer was rejected"); - try_catch.ReThrow(); - return; + if (user_cached_data.has_value() && user_cached_data.value() != nullptr) { +#ifndef DISABLE_SINGLE_EXECUTABLE_APPLICATION + if (id_symbol == realm->isolate_data()->embedder_module_hdo() && + sea::IsSingleExecutable()) { + if (cache_rejected) { + per_process::Debug(DebugCategory::SEA, + "SEA module code cache rejected\n"); + ProcessEmitWarningSync(realm->env(), "Code cache data rejected."); + } else { + per_process::Debug(DebugCategory::SEA, + "SEA module code cache accepted\n"); + } + } else // NOLINT(readability/braces) +#endif // !DISABLE_SINGLE_EXECUTABLE_APPLICATION + if (cache_rejected) { + THROW_ERR_VM_MODULE_CACHED_DATA_REJECTED( + realm, "cachedData buffer was rejected"); + try_catch.ReThrow(); + return; + } } if (that->Set(context, diff --git a/src/node_sea.cc b/src/node_sea.cc index 1f340cf56b21aa..1be41e6f14146e 100644 --- a/src/node_sea.cc +++ b/src/node_sea.cc @@ -24,6 +24,7 @@ using v8::Array; using v8::ArrayBuffer; using v8::BackingStore; using v8::Context; +using v8::Data; using v8::Function; using v8::FunctionCallbackInfo; using v8::HandleScope; @@ -31,11 +32,13 @@ using v8::Isolate; using v8::Local; using v8::LocalVector; using v8::MaybeLocal; +using v8::Module; using v8::NewStringType; using v8::Object; using v8::ScriptCompiler; using v8::ScriptOrigin; using v8::String; +using v8::UnboundModuleScript; using v8::Value; namespace node { @@ -542,7 +545,7 @@ std::optional ParseSingleExecutableConfig( "\"useCodeCache\" is redundant when \"useSnapshot\" is true\n"); } - // TODO(joyeecheung): support ESM with useSnapshot and useCodeCache. + // TODO(joyeecheung): support ESM with useSnapshot. if (result.main_format == ModuleFormat::kModule && static_cast(result.flags & SeaFlags::kUseSnapshot)) { FPrintF(stderr, @@ -551,14 +554,6 @@ std::optional ParseSingleExecutableConfig( return std::nullopt; } - if (result.main_format == ModuleFormat::kModule && - static_cast(result.flags & SeaFlags::kUseCodeCache)) { - FPrintF(stderr, - "\"mainFormat\": \"module\" is not supported when " - "\"useCodeCache\" is true\n"); - return std::nullopt; - } - if (result.main_path.empty()) { FPrintF(stderr, "\"main\" field of %s is not a non-empty string\n", @@ -616,7 +611,8 @@ ExitCode GenerateSnapshotForSEA(const SeaConfig& config, } std::optional GenerateCodeCache(std::string_view main_path, - std::string_view main_script) { + std::string_view main_script, + ModuleFormat format) { RAIIIsolate raii_isolate(SnapshotBuilder::GetEmbeddedSnapshotData()); Isolate* isolate = raii_isolate.get(); @@ -647,34 +643,62 @@ std::optional GenerateCodeCache(std::string_view main_path, return std::nullopt; } - LocalVector parameters( - isolate, - { - FIXED_ONE_BYTE_STRING(isolate, "exports"), - FIXED_ONE_BYTE_STRING(isolate, "require"), - FIXED_ONE_BYTE_STRING(isolate, "module"), - FIXED_ONE_BYTE_STRING(isolate, "__filename"), - FIXED_ONE_BYTE_STRING(isolate, "__dirname"), - }); - ScriptOrigin script_origin(filename, 0, 0, true); - ScriptCompiler::Source script_source(content, script_origin); - MaybeLocal maybe_fn = - ScriptCompiler::CompileFunction(context, - &script_source, - parameters.size(), - parameters.data(), - 0, - nullptr); - Local fn; - if (!maybe_fn.ToLocal(&fn)) { - return std::nullopt; + std::unique_ptr cache; + + if (format == ModuleFormat::kModule) { + // Using empty host defined options is fine as it is not part of the cache + // key and will be reset after deserialization. + ScriptOrigin origin(filename, + 0, // line offset + 0, // column offset + true, // is cross origin + -1, // script id + Local(), // source map URL + false, // is opaque + false, // is WASM + true, // is ES Module + Local()); // host defined options + ScriptCompiler::Source source(content, origin); + Local module; + if (!ScriptCompiler::CompileModule(isolate, &source).ToLocal(&module)) { + return std::nullopt; + } + Local unbound = module->GetUnboundModuleScript(); + cache.reset(ScriptCompiler::CreateCodeCache(unbound)); + } else { + // TODO(RaisinTen): Using the V8 code cache prevents us from using + // `import()` in the SEA code. Support it. Refs: + // https://github.com/nodejs/node/pull/48191#discussion_r1213271430 + // TODO(joyeecheung): this likely has been fixed by + // https://chromium-review.googlesource.com/c/v8/v8/+/5401780 - add a test + // and update docs. + LocalVector parameters( + isolate, + { + FIXED_ONE_BYTE_STRING(isolate, "exports"), + FIXED_ONE_BYTE_STRING(isolate, "require"), + FIXED_ONE_BYTE_STRING(isolate, "module"), + FIXED_ONE_BYTE_STRING(isolate, "__filename"), + FIXED_ONE_BYTE_STRING(isolate, "__dirname"), + }); + ScriptOrigin script_origin(filename, 0, 0, true); + ScriptCompiler::Source script_source(content, script_origin); + Local fn; + if (!ScriptCompiler::CompileFunction(context, + &script_source, + parameters.size(), + parameters.data(), + 0, + nullptr) + .ToLocal(&fn)) { + return std::nullopt; + } + cache.reset(ScriptCompiler::CreateCodeCacheForFunction(fn)); } - // TODO(RaisinTen): Using the V8 code cache prevents us from using `import()` - // in the SEA code. Support it. - // Refs: https://github.com/nodejs/node/pull/48191#discussion_r1213271430 - std::unique_ptr cache{ - ScriptCompiler::CreateCodeCacheForFunction(fn)}; + if (!cache) { + return std::nullopt; + } std::string code_cache(cache->data, cache->data + cache->length); return code_cache; } @@ -728,7 +752,7 @@ ExitCode GenerateSingleExecutableBlob( std::string code_cache; if (static_cast(config.flags & SeaFlags::kUseCodeCache)) { std::optional optional_code_cache = - GenerateCodeCache(config.main_path, main_script); + GenerateCodeCache(config.main_path, main_script, config.main_format); if (!optional_code_cache.has_value()) { FPrintF(stderr, "Cannot generate V8 code cache\n"); return ExitCode::kGenericUserError; diff --git a/test/fixtures/sea/esm-code-cache/sea-config.json b/test/fixtures/sea/esm-code-cache/sea-config.json new file mode 100644 index 00000000000000..53ba60cc157bde --- /dev/null +++ b/test/fixtures/sea/esm-code-cache/sea-config.json @@ -0,0 +1,7 @@ +{ + "main": "sea.mjs", + "output": "sea", + "mainFormat": "module", + "useCodeCache": true, + "disableExperimentalSEAWarning": true +} diff --git a/test/fixtures/sea/esm-code-cache/sea.mjs b/test/fixtures/sea/esm-code-cache/sea.mjs new file mode 100644 index 00000000000000..b2605a30ed0b63 --- /dev/null +++ b/test/fixtures/sea/esm-code-cache/sea.mjs @@ -0,0 +1,20 @@ +import assert from 'node:assert'; +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; +import { dirname } from 'node:path'; + +// Test createRequire with process.execPath. +const assert2 = createRequire(process.execPath)('node:assert'); +assert.strictEqual(assert2.strict, assert.strict); + +// Test import.meta properties. +assert.strictEqual(import.meta.url, pathToFileURL(process.execPath).href); +assert.strictEqual(import.meta.filename, process.execPath); +assert.strictEqual(import.meta.dirname, dirname(process.execPath)); +assert.strictEqual(import.meta.main, true); + +// Test import() with a built-in module. +const { strict } = await import('node:assert'); +assert.strictEqual(strict, assert.strict); + +console.log('ESM SEA with code cache executed successfully'); diff --git a/test/sea/test-single-executable-application-esm-code-cache.js b/test/sea/test-single-executable-application-esm-code-cache.js new file mode 100644 index 00000000000000..1fe6dff7a3da25 --- /dev/null +++ b/test/sea/test-single-executable-application-esm-code-cache.js @@ -0,0 +1,34 @@ +'use strict'; + +// This tests the creation of a single executable application with an ESM +// entry point using "mainFormat": "module" and "useCodeCache": true. + +require('../common'); + +const { + buildSEA, + skipIfBuildSEAIsNotSupported, +} = require('../common/sea'); + +skipIfBuildSEAIsNotSupported(); + +const tmpdir = require('../common/tmpdir'); +const fixtures = require('../common/fixtures'); +const { spawnSyncAndExitWithoutError } = require('../common/child_process'); + +tmpdir.refresh(); + +const outputFile = buildSEA(fixtures.path('sea', 'esm-code-cache')); + +spawnSyncAndExitWithoutError( + outputFile, + { + env: { + NODE_DEBUG_NATIVE: 'SEA', + ...process.env, + }, + }, + { + stdout: /ESM SEA with code cache executed successfully/, + stderr: /SEA module code cache accepted/, + });