Skip to content

Commit 267c35f

Browse files
feat: implement EsmModuleLoader for ESM support (clice-io#97)
1 parent 28d677f commit 267c35f

9 files changed

Lines changed: 525 additions & 39 deletions

File tree

src/catter/core/js/esm_loader.cc

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
#include "esm_loader.h"
2+
3+
#include <format>
4+
#include <fstream>
5+
#include <iterator>
6+
7+
namespace catter::js {
8+
9+
namespace {
10+
11+
bool is_path_specifier(std::string_view specifier) {
12+
return specifier.starts_with("./") || specifier.starts_with("../") || specifier == "." ||
13+
specifier == ".." || std::filesystem::path(specifier).is_absolute();
14+
}
15+
16+
std::filesystem::path absolute_normalized(std::filesystem::path path,
17+
const std::filesystem::path& working_directory) {
18+
if(path.is_relative()) {
19+
path = working_directory / std::move(path);
20+
}
21+
return std::filesystem::absolute(path).lexically_normal();
22+
}
23+
24+
} // namespace
25+
26+
EsmModuleLoader::EsmModuleLoader(std::filesystem::path working_directory) :
27+
working_directory(std::filesystem::absolute(std::move(working_directory)).lexically_normal()) {}
28+
29+
std::filesystem::path EsmModuleLoader::resolve_path(const char* referrer_name,
30+
const char* module_name) const {
31+
const std::string_view specifier{module_name ? module_name : ""};
32+
33+
if(specifier.starts_with("catter")) {
34+
return specifier;
35+
}
36+
37+
if(!is_path_specifier(specifier)) {
38+
throw qjs::Exception("Unsupported ESM module specifier '{}'; only file paths are supported",
39+
specifier);
40+
}
41+
42+
std::filesystem::path base = this->working_directory;
43+
if(referrer_name && *referrer_name) {
44+
auto referrer = std::filesystem::path(referrer_name);
45+
if(referrer.is_relative()) {
46+
referrer = this->working_directory / std::move(referrer);
47+
}
48+
base = referrer.parent_path();
49+
}
50+
51+
auto resolved = absolute_normalized(std::filesystem::path(specifier), base);
52+
std::error_code ec;
53+
const bool exists = std::filesystem::exists(resolved, ec);
54+
if(ec || !exists) {
55+
throw qjs::Exception("Cannot find module '{}' imported from '{}'",
56+
specifier,
57+
referrer_name ? referrer_name : "<entry>");
58+
}
59+
if(std::filesystem::is_directory(resolved, ec)) {
60+
throw qjs::Exception("Directory import '{}' is not supported", resolved.string());
61+
}
62+
if(ec || !std::filesystem::is_regular_file(resolved, ec) || ec) {
63+
throw qjs::Exception("Cannot load module '{}'", resolved.string());
64+
}
65+
return resolved;
66+
}
67+
68+
std::string EsmModuleLoader::normalizer(const char* referrer_name, const char* module_name) {
69+
return resolve_path(referrer_name, module_name).string();
70+
}
71+
72+
std::string EsmModuleLoader::loader(const char* module_name) {
73+
const auto path = resolve_path(nullptr, module_name);
74+
std::ifstream input(path, std::ios::binary);
75+
if(!input) {
76+
throw qjs::Exception("Failed to read module '{}'", path.string());
77+
}
78+
std::string source{std::istreambuf_iterator<char>{input}, std::istreambuf_iterator<char>{}};
79+
return source;
80+
}
81+
} // namespace catter::js

src/catter/core/js/esm_loader.h

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
#pragma once
2+
3+
#include <filesystem>
4+
#include <memory>
5+
#include <string>
6+
#include <vector>
7+
8+
#include "qjs.h"
9+
10+
namespace catter::js {
11+
12+
/**
13+
* Path-only ESM loader.
14+
*
15+
* Specifiers are resolved relative to the importing file. Explicit absolute paths are also
16+
* accepted. Extensions are never inferred and directory imports are rejected, matching Node's
17+
* ESM path rules. All loaded files are treated as ES modules by the caller.
18+
*/
19+
class EsmModuleLoader final : public qjs::Runtime::ModuleLoader {
20+
public:
21+
explicit EsmModuleLoader(std::filesystem::path working_directory);
22+
23+
std::string normalizer(const char* referrer_name, const char* module_name) override;
24+
std::string loader(const char* module_name) override;
25+
26+
private:
27+
std::filesystem::path resolve_path(const char* referrer_name, const char* module_name) const;
28+
29+
std::filesystem::path working_directory;
30+
};
31+
} // namespace catter::js

src/catter/core/js/js.cc

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
#include "apitool.h"
1313
#include "async.h"
14+
#include "esm_loader.h"
1415

1516
extern "C" {
1617
extern const char _binary_lib_js_start[];
@@ -44,19 +45,13 @@ struct RuntimeState {
4445
on_command = {};
4546
on_execution = {};
4647
runtime = qjs::Runtime::create();
48+
runtime.set_module_loader(std::make_unique<EsmModuleLoader>(next_config.pwd));
4749
config = std::move(next_config);
4850
}
4951
};
5052

5153
RuntimeState state{};
5254

53-
void register_catter_module(const qjs::Context& ctx) {
54-
auto& mod = ctx.cmodule("catter-c");
55-
for(auto& reg: catter::apitool::api_registers()) {
56-
reg(mod, ctx);
57-
}
58-
}
59-
6055
std::string_view js_lib_source() {
6156
const std::string_view js_lib{_binary_lib_js_start, _binary_lib_js_end};
6257
auto last = js_lib.find_last_not_of('\0');
@@ -67,11 +62,8 @@ std::string_view js_lib_source() {
6762
}
6863

6964
kota::task<> eval_module(std::string_view input, const char* filename) {
70-
constexpr int flags = JS_EVAL_FLAG_STRICT;
71-
7265
auto& ctx = state.runtime.context();
73-
auto result =
74-
co_await state.js_loop.promise_to_task<void>(ctx.eval_module(input, filename, flags));
66+
auto result = co_await state.js_loop.promise_to_task<void>(ctx.eval_module(input, filename));
7567
if(!result) {
7668
throw result.error().to_exception();
7769
}
@@ -113,8 +105,10 @@ kota::task<> RuntimeScope::start(RuntimeConfig config) {
113105
std::exception_ptr error;
114106
try {
115107
const auto& ctx = state.runtime.context();
116-
register_catter_module(ctx);
117-
108+
auto& mod = ctx.cmodule("catter-c");
109+
for(auto& reg: catter::apitool::api_registers()) {
110+
reg(mod, ctx);
111+
}
118112
co_await eval_module(js_lib_source(), "catter");
119113
} catch(...) {
120114
error = std::current_exception();

src/catter/core/js/qjs.cc

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -506,18 +506,6 @@ Value Context::eval(std::string_view input, const char* filename, int eval_flags
506506
return this->eval(input.data(), input.size(), filename, eval_flags);
507507
}
508508

509-
Promise Context::eval_module(const char* input,
510-
size_t input_len,
511-
const char* filename,
512-
int eval_flags) const {
513-
return this->eval(input, input_len, filename, eval_flags | JS_EVAL_TYPE_MODULE).as<Promise>();
514-
}
515-
516-
Promise Context::eval_module(std::string_view input, const char* filename, int eval_flags) const {
517-
return this->eval(input.data(), input.size(), filename, eval_flags | JS_EVAL_TYPE_MODULE)
518-
.as<Promise>();
519-
}
520-
521509
Object Context::global_this() const noexcept {
522510
return Object{this->js_context(), JS_GetGlobalObject(this->js_context())};
523511
}

src/catter/core/js/qjs.h

Lines changed: 115 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1058,12 +1058,13 @@ template <class Num>
10581058
requires std::is_integral_v<Num>
10591059
struct value_trans<Num> {
10601060
static Value from(JSContext* ctx, Num value) noexcept {
1061-
if constexpr(std::is_unsigned_v<Num> && sizeof(Num) <= sizeof(uint32_t)) {
1062-
return Value{ctx, JS_NewUint32(ctx, static_cast<uint32_t>(value))};
1063-
} else if constexpr(std::is_signed_v<Num>) {
1064-
return Value{ctx, JS_NewInt64(ctx, static_cast<int64_t>(value))};
1061+
static_assert(sizeof(Num) <= sizeof(uint64_t),
1062+
"Integral type is too large to be represented in JavaScript");
1063+
1064+
if constexpr(std::is_unsigned_v<Num>) {
1065+
return Value{ctx, JS_NewUint64(ctx, static_cast<uint64_t>(value))};
10651066
} else {
1066-
static_assert(kota::dependent_false<Num>, "Unsupported integral type for value");
1067+
return Value{ctx, JS_NewInt64(ctx, static_cast<int64_t>(value))};
10671068
}
10681069
}
10691070

@@ -1416,15 +1417,50 @@ class Context {
14161417
Value eval(std::string_view input, const char* filename, int eval_flags) const;
14171418

14181419
/**
1419-
* Evaluate a JavaScript module, it will automatically add flag JS_EVAL_TYPE_MODULE to
1420-
* eval_flags.
1420+
* Evaluate a strict-mode classic script synchronously in this context's global scope.
1421+
* The returned value is the script's completion value.
1422+
*/
1423+
Value eval_script(const char* input, size_t input_len, const char* filename) const {
1424+
return this->eval(input, input_len, filename, JS_EVAL_TYPE_GLOBAL | JS_EVAL_FLAG_STRICT);
1425+
}
1426+
1427+
/** String-view overload of eval_script(). */
1428+
Value eval_script(std::string_view input, const char* filename) const {
1429+
return this->eval_script(input.data(), input.size(), filename);
1430+
}
1431+
1432+
/**
1433+
* Evaluate a strict-mode classic script with top-level await support.
1434+
* The returned promise settles when the script and all of its top-level awaits complete.
1435+
*/
1436+
Promise async_eval_script(const char* input, size_t input_len, const char* filename) const {
1437+
return this
1438+
->eval(input,
1439+
input_len,
1440+
filename,
1441+
JS_EVAL_TYPE_GLOBAL | JS_EVAL_FLAG_ASYNC | JS_EVAL_FLAG_STRICT)
1442+
.as<Promise>();
1443+
}
1444+
1445+
/** String-view overload of async_eval_script(). */
1446+
Promise async_eval_script(std::string_view input, const char* filename) const {
1447+
return this->async_eval_script(input.data(), input.size(), filename);
1448+
}
1449+
1450+
/**
1451+
* Evaluate a strict-mode ECMAScript module.
1452+
* Imports are resolved through the Runtime module loader, and the returned promise settles
1453+
* after module evaluation, including top-level awaits, has completed.
14211454
*/
1422-
Promise eval_module(const char* input,
1423-
size_t input_len,
1424-
const char* filename,
1425-
int eval_flags) const;
1455+
Promise eval_module(const char* input, size_t input_len, const char* filename) const {
1456+
return this->eval(input, input_len, filename, JS_EVAL_TYPE_MODULE | JS_EVAL_FLAG_STRICT)
1457+
.as<Promise>();
1458+
}
14261459

1427-
Promise eval_module(std::string_view input, const char* filename, int eval_flags) const;
1460+
/** String-view overload of eval_module(). */
1461+
Promise eval_module(std::string_view input, const char* filename) const {
1462+
return this->eval_module(input.data(), input.size(), filename);
1463+
}
14281464

14291465
Object global_this() const noexcept;
14301466

@@ -1480,6 +1516,17 @@ class Runtime {
14801516
Runtime& operator= (Runtime&&) = default;
14811517
~Runtime() = default;
14821518

1519+
/** Callbacks used to resolve and load JavaScript source modules. */
1520+
struct ModuleLoader {
1521+
/** Resolve module_name relative to referrer_name and return its canonical module name. */
1522+
virtual std::string normalizer(const char* referrer_name, const char* module_name) = 0;
1523+
1524+
/** Return the JavaScript source bytes for a canonical module name. */
1525+
virtual std::string loader(const char* module_name) = 0;
1526+
1527+
virtual ~ModuleLoader() = default;
1528+
};
1529+
14831530
static Runtime create();
14841531

14851532
// Get or create a context with the given name
@@ -1488,6 +1535,61 @@ class Runtime {
14881535

14891536
JSRuntime* js_runtime() const noexcept;
14901537

1538+
/**
1539+
* Install or replace this runtime's JavaScript module loader.
1540+
* The callbacks are retained by the runtime and used by subsequent module evaluations.
1541+
*/
1542+
void set_module_loader(std::unique_ptr<ModuleLoader> loader) const noexcept {
1543+
this->raw->module_loader = std::move(loader);
1544+
1545+
return JS_SetModuleLoaderFunc(
1546+
this->js_runtime(),
1547+
[](JSContext* ctx, const char* module_base_name, const char* module_name, void* opaque)
1548+
-> char* {
1549+
auto raw = static_cast<Raw*>(opaque);
1550+
assert(raw && raw->module_loader && "Module loader is not set");
1551+
try {
1552+
auto normalized_name =
1553+
raw->module_loader->normalizer(module_base_name, module_name);
1554+
1555+
return js_strdup(ctx, normalized_name.c_str());
1556+
} catch(const std::exception& e) {
1557+
JS_ThrowInternalError(ctx, "Exception in module normalizer: %s", e.what());
1558+
return nullptr;
1559+
} catch(...) {
1560+
JS_ThrowInternalError(ctx, "Unknown exception in module normalizer");
1561+
return nullptr;
1562+
}
1563+
},
1564+
[](JSContext* ctx, const char* module_name, void* opaque) -> JSModuleDef* {
1565+
auto raw = static_cast<Raw*>(opaque);
1566+
assert(raw && raw->module_loader && "Module loader is not set");
1567+
1568+
try {
1569+
auto source = raw->module_loader->loader(module_name);
1570+
auto module_value =
1571+
Value{ctx,
1572+
JS_Eval(ctx,
1573+
source.data(),
1574+
source.size(),
1575+
module_name,
1576+
JS_EVAL_TYPE_MODULE | JS_EVAL_FLAG_COMPILE_ONLY)};
1577+
1578+
if(module_value.is_exception())
1579+
return NULL;
1580+
1581+
return (JSModuleDef*)JS_VALUE_GET_PTR(module_value.value());
1582+
} catch(const std::exception& e) {
1583+
JS_ThrowInternalError(ctx, "Exception in module loader: %s", e.what());
1584+
return nullptr;
1585+
} catch(...) {
1586+
JS_ThrowInternalError(ctx, "Unknown exception in module loader");
1587+
return nullptr;
1588+
}
1589+
},
1590+
this->raw.get());
1591+
}
1592+
14911593
bool has_job_pending() const noexcept {
14921594
return JS_IsJobPending(this->js_runtime());
14931595
}
@@ -1533,6 +1635,7 @@ class Runtime {
15331635
void operator() (JSRuntime* rt) const noexcept;
15341636
};
15351637

1638+
std::unique_ptr<ModuleLoader> module_loader = nullptr;
15361639
std::unique_ptr<JSRuntime, JSRuntimeDeleter> rt = nullptr;
15371640
std::unordered_map<std::string, Context> ctxs{};
15381641
};

src/catter/core/option.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ struct CatterConfig {
8484
DecoKV(names = {"-m", "--mode"},
8585
meta_var = "<Mode>",
8686
help = "mode of operation, e.g. 'inject'",
87-
required = true)
87+
required = false)
8888
<config::RunMode> mode = config::RunMode{};
8989

9090
DecoKV(names = {"-d", "--dir"},

0 commit comments

Comments
 (0)