Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions SCsub
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,11 @@ jsb_defines = [
"`devtools` does not respond to the upgrade request with a `sec-websocket-protocol` header which does not apply the handshake requirements of `WSLPeer`",
"and the connection will break immediately by `devtools` if `selected_protocol` is assigned manually in `WSLPeer`",
]),
CompileDefines("JSB_NATIVE_ESM", 1 if env["jsb_native_esm"] else 0, [
"Enable the additive native ESM resolver shell (.mjs only).",
"When enabled, `.mjs` modules are compiled via `v8::ScriptCompiler::CompileModule`; all other extensions stay on the CJS path.",
"V8 only. Default off. See `bridge/jsb_native_esm_resolver.{h,cpp}`.",
]),
]

def is_defined(name, value = 1):
Expand Down
10 changes: 10 additions & 0 deletions bridge/jsb_environment.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
#include "jsb_worker.h"
#include "jsb_essentials.h"
#include "jsb_amd_module_loader.h"
#include "jsb_native_esm_resolver.h"
#include "jsb_thread_safe_for_nodes_scope.h"

#include "../internal/jsb_path_util.h"
Expand Down Expand Up @@ -447,7 +448,16 @@

void Environment::init()
{
#if JSB_NATIVE_ESM && JSB_WITH_V8
// registered FIRST so `.mjs` files are claimed by the native ESM path before the CJS default takes over.
this->add_module_resolver<jsb::NativeESMModuleResolver>()
.add_search_path(jsb::internal::Settings::get_jsb_out_res_path())
.add_search_path("res://")
.add_search_path("res://node_modules")
;
#endif

jsb::DefaultModuleResolver& resolver = this->add_module_resolver<jsb::DefaultModuleResolver>()

Check warning on line 460 in bridge/jsb_environment.cpp

View workflow job for this annotation

GitHub Actions / ⚙️ Build Godot Version with GodotJS (4.6.1, 4.6, 4.6.1-stable) / 🤖 Android / Android (template_debug, qjs-ng)

unused variable 'resolver'

Check warning on line 460 in bridge/jsb_environment.cpp

View workflow job for this annotation

GitHub Actions / ⚙️ Build Godot Version with GodotJS (4.6.1, 4.6, 4.6.1-stable) / 🍏 iOS / iOS (template_debug, qjs-ng)

unused variable 'resolver'

Check warning on line 460 in bridge/jsb_environment.cpp

View workflow job for this annotation

GitHub Actions / ⚙️ Build Godot Version with GodotJS (4.6.1, 4.6, 4.6.1-stable) / 🌐 Web / Web (template_debug, qjs-ng, threads=yes, dlink=no)

unused variable 'resolver'

Check warning on line 460 in bridge/jsb_environment.cpp

View workflow job for this annotation

GitHub Actions / ⚙️ Build Godot Version with GodotJS (4.6.1, 4.6, 4.6.1-stable) / 🌐 Web / Web (template_debug, browser, threads=yes, dlink=no)

unused variable 'resolver'

Check warning on line 460 in bridge/jsb_environment.cpp

View workflow job for this annotation

GitHub Actions / ⚙️ Build Godot Version with GodotJS (4.6.1, 4.6, 4.6.1-stable) / 🍎 macOS / Mac (editor, qjs-ng)

unused variable 'resolver'

Check warning on line 460 in bridge/jsb_environment.cpp

View workflow job for this annotation

GitHub Actions / ⚙️ Build Godot Version with GodotJS (4.6.1, 4.6, 4.6.1-stable) / 🍎 macOS / Mac (editor, qjs-ng)

unused variable 'resolver'

Check warning on line 460 in bridge/jsb_environment.cpp

View workflow job for this annotation

GitHub Actions / ⚙️ Build Godot Version with GodotJS (4.6.1, 4.6, 4.6.1-stable) / 🏁 Windows / Windows (editor, v8)

'resolver': local variable is initialized but not referenced
.add_search_path(jsb::internal::Settings::get_jsb_out_res_path()) // default path of js source (results of compiled ts, at '.godot/GodotJS' by default)
.add_search_path("res://") // use the root directory as custom lib path by default
.add_search_path("res://node_modules") // so far, it's the only supported path for node_modules in GodotJS
Expand Down
20 changes: 20 additions & 0 deletions bridge/jsb_environment.h
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,13 @@ namespace jsb
HashMap<StringName, class IModuleLoader*> module_loaders_;
Vector<IModuleResolver*> module_resolvers_;

#if JSB_NATIVE_ESM && JSB_WITH_V8
// back-lookup from `v8::Module::ScriptId()` to the owning `JavaScriptModule.id`.
// populated by NativeESMModuleResolver before `InstantiateModule` so the static
// `ResolveModuleCallback` can resolve relative specifiers against the referrer's path.
HashMap<int, StringName> esm_script_id_to_module_id_;
#endif

#if JSB_WITH_ESSENTIALS
JSTimerTags<uint64_t> timer_tags_;
internal::TTimerManager<JavaScriptTimerAction> timer_manager_;
Expand Down Expand Up @@ -563,6 +570,19 @@ namespace jsb
return *resolver;
}

#if JSB_NATIVE_ESM && JSB_WITH_V8
jsb_force_inline void register_esm_module_script_id(int p_script_id, const StringName& p_module_id)
{
esm_script_id_to_module_id_.insert(p_script_id, p_module_id);
}

jsb_force_inline StringName find_esm_module_id_by_script_id(int p_script_id) const
{
const HashMap<int, StringName>::ConstIterator it = esm_script_id_to_module_id_.find(p_script_id);
return it != esm_script_id_to_module_id_.end() ? it->value : StringName();
}
#endif

/**
* \brief
* \param p_type category of the class, a GodotObject class is also registered in `godot_classes_index` map
Expand Down
6 changes: 6 additions & 0 deletions bridge/jsb_module.h
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ namespace jsb
// the default class exported in this JS module
ScriptClassID script_class_id;

#if JSB_NATIVE_ESM && JSB_WITH_V8
// populated by NativeESMModuleResolver after `v8::ScriptCompiler::CompileModule`.
// empty when this module came in via the CJS path.
v8::Global<v8::Module> esm_module;
#endif

#if JSB_SUPPORT_RELOAD && defined(TOOLS_ENABLED)
bool reload_requested = false;
uint64_t time_modified = 0;
Expand Down
139 changes: 139 additions & 0 deletions bridge/jsb_native_esm_resolver.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
#include "jsb_native_esm_resolver.h"

#if JSB_NATIVE_ESM && JSB_WITH_V8

#include "jsb_environment.h"

#include "../internal/jsb_path_util.h"

namespace jsb
{
bool NativeESMModuleResolver::get_source_info(const String& p_module_id, ModuleSourceInfo& r_source_info)
{
if (!DefaultModuleResolver::get_source_info(p_module_id, r_source_info))
{
return false;
}
if (!r_source_info.source_filepath.ends_with("." JSB_MODULE_EXT))
{
r_source_info = {};
return false;
}
return true;
}

v8::MaybeLocal<v8::Module> NativeESMModuleResolver::resolve_module_callback(
v8::Local<v8::Context> p_context,
v8::Local<v8::String> p_specifier,
v8::Local<v8::FixedArray> /*p_import_attributes*/,
v8::Local<v8::Module> p_referrer)
{
v8::Isolate* isolate = p_context->GetIsolate();
Environment* env = Environment::wrap(p_context);
const String specifier = impl::Helper::to_string(isolate, p_specifier);
const String parent_id = env->find_esm_module_id_by_script_id(p_referrer->ScriptId());

JavaScriptModule* child = env->_load_module(parent_id, specifier);
if (!child || child->esm_module.IsEmpty())
{
return v8::MaybeLocal<v8::Module>();
}
return child->esm_module.Get(isolate);
}

bool NativeESMModuleResolver::load(Environment* p_env, const String& p_asset_path, JavaScriptModule& p_module)
{
v8::Isolate* isolate = p_env->get_isolate();
const v8::Local<v8::Context> context = isolate->GetCurrentContext();

internal::FileAccessSourceReader reader(p_asset_path);
if (reader.is_null() || reader.get_length() == 0)
{
jsb_throw(isolate, "failed to read module source");
return false;
}

#if JSB_SUPPORT_RELOAD && defined(TOOLS_ENABLED)
p_module.time_modified = reader.get_time_modified();
p_module.hash = reader.get_hash();
#endif

const uint64_t source_len = reader.get_length();
Vector<uint8_t> source_bytes;
source_bytes.resize((int) source_len + 1);
source_bytes.write[(int) source_len] = 0;
reader.get_buffer(source_bytes.ptrw(), source_len);

const v8::Local<v8::String> source_string = v8::String::NewFromUtf8(
isolate, (const char*) source_bytes.ptr(), v8::NewStringType::kNormal, (int) source_len).ToLocalChecked();
const v8::Local<v8::String> resource_name = impl::Helper::new_string(isolate, p_asset_path);
v8::ScriptOrigin origin(
resource_name,
/* resource_line_offset */ 0,
/* resource_column_offset */ 0,
/* resource_is_shared_cross_origin */ false,
/* script_id */ -1,
/* source_map_url */ v8::Local<v8::Value>(),
/* resource_is_opaque */ false,
/* is_wasm */ false,
/* is_module */ true);
v8::ScriptCompiler::Source script_source(source_string, origin);

v8::Local<v8::Module> module;
if (!v8::ScriptCompiler::CompileModule(isolate, &script_source).ToLocal(&module))
{
return false;
}

// map referrer script_id -> module_id BEFORE InstantiateModule so the resolve callback can find us.
p_env->register_esm_module_script_id(module->ScriptId(), p_module.id);

v8::Maybe<bool> instantiated = module->InstantiateModule(context, &resolve_module_callback);
if (instantiated.IsNothing() || !instantiated.FromJust())
{
return false;
}

v8::Local<v8::Value> evaluation_result;
if (!module->Evaluate(context).ToLocal(&evaluation_result))
{
return false;
}

// drain microtasks so top-level await + the Evaluate() promise settle before we inspect status.
isolate->PerformMicrotaskCheckpoint();

if (module->GetStatus() == v8::Module::kErrored)
{
isolate->ThrowException(module->GetException());
return false;
}

if (evaluation_result->IsPromise())
{
const v8::Local<v8::Promise> promise = evaluation_result.As<v8::Promise>();
if (promise->State() == v8::Promise::kRejected)
{
isolate->ThrowException(promise->Result());
return false;
}
}

const v8::Local<v8::Value> namespace_value = module->GetModuleNamespace();
const v8::Local<v8::Object> exports_obj = namespace_value->IsObject()
? namespace_value.As<v8::Object>()
: v8::Object::New(isolate);

const String dirname = internal::PathUtil::dirname(p_asset_path);
const v8::Local<v8::Object> module_obj = p_module.module.Get(isolate);
module_obj->Set(context, jsb_name(p_env, filename), impl::Helper::new_string(isolate, p_asset_path)).Check();
module_obj->Set(context, jsb_name(p_env, path), impl::Helper::new_string(isolate, dirname)).Check();
module_obj->Set(context, jsb_name(p_env, exports), exports_obj).Check();

p_module.exports.Reset(isolate, exports_obj);
p_module.esm_module.Reset(isolate, module);
return true;
}
}

#endif // JSB_NATIVE_ESM && JSB_WITH_V8
34 changes: 34 additions & 0 deletions bridge/jsb_native_esm_resolver.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#ifndef GODOTJS_NATIVE_ESM_RESOLVER_H
#define GODOTJS_NATIVE_ESM_RESOLVER_H

#include "jsb_bridge_pch.h"

#if JSB_NATIVE_ESM && JSB_WITH_V8

#include "jsb_module_resolver.h"

namespace jsb
{
// Loads `.mjs` modules via `v8::ScriptCompiler::CompileModule` + `Module::InstantiateModule`.
// Inherits search-path resolution from `DefaultModuleResolver` and post-filters to `.mjs` only,
// so existing `.js` / `.cjs` modules keep their CJS load path untouched.
class NativeESMModuleResolver : public DefaultModuleResolver
{
public:
virtual ~NativeESMModuleResolver() override = default;

virtual bool get_source_info(const String& p_module_id, ModuleSourceInfo& r_source_info) override;
virtual bool load(Environment* p_env, const String& p_asset_path, JavaScriptModule& p_module) override;

private:
static v8::MaybeLocal<v8::Module> resolve_module_callback(
v8::Local<v8::Context> p_context,
v8::Local<v8::String> p_specifier,
v8::Local<v8::FixedArray> p_import_attributes,
v8::Local<v8::Module> p_referrer);
};
}

#endif // JSB_NATIVE_ESM && JSB_WITH_V8

#endif
1 change: 1 addition & 0 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ def get_opts(platform):
BoolVariable("use_jsc", "Prefer to use JavaScriptCore (only for macos and ios)", False),
BoolVariable("use_quickjs", "Prefer to use QuickJS rather than the default VM on the current platform", False),
BoolVariable("use_quickjs_ng", "Prefer to use QuickJS-NG rather than the default VM on the current platform", False),
BoolVariable("jsb_native_esm", "Enable the native ESM resolver shell (V8 only, additive, P1 of CJS->ESM migration)", False),
]

def configure(env):
Expand Down
8 changes: 8 additions & 0 deletions tests/project/tests/esm/entry.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import answer, { HELLO } from "./hello";

if (answer !== 42) {
throw new Error(`default export mismatch: expected 42, got ${answer}`);
}
if (HELLO !== "world") {
throw new Error(`named export mismatch: expected "world", got ${HELLO}`);
}
1 change: 1 addition & 0 deletions tests/project/tests/esm/entry.mjs.uid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://cfuqjp273rc3w
2 changes: 2 additions & 0 deletions tests/project/tests/esm/hello.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const HELLO = "world";
export default 42;
1 change: 1 addition & 0 deletions tests/project/tests/esm/hello.mjs.uid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://ckedluu4w22bw
25 changes: 25 additions & 0 deletions tests/test_jsb_esm_runtime.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#ifndef GODOTJS_TESTS_JSB_ESM_RUNTIME_H
#define GODOTJS_TESTS_JSB_ESM_RUNTIME_H

#include "jsb_test_helpers.h"

#if JSB_NATIVE_ESM && JSB_WITH_V8

namespace jsb::tests
{
TEST_CASE("[jsb]esm load native esm module")
{
GodotJSScriptLanguageIniter initer;
const std::shared_ptr<jsb::Environment> env = GodotJSScriptLanguage::get_singleton()->get_environment();

JSB_TESTS_EXECUTION_SCOPE(env.get());
JavaScriptModule* mod = nullptr;
CHECK(env->load("tests/esm/entry", &mod) == OK);
CHECK(mod != nullptr);
CHECK(!mod->esm_module.IsEmpty());
}
}

#endif // JSB_NATIVE_ESM && JSB_WITH_V8

#endif
Loading