Skip to content

Commit 84377e0

Browse files
committed
Feat: Implement CommonJS require() support
1 parent f83416f commit 84377e0

4 files changed

Lines changed: 176 additions & 0 deletions

File tree

src/quickjs_runtime/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from _quickjs import Context as _Context
22
import sys
3+
from .require import Require
34

45
class Context:
56
"""

src/quickjs_runtime/repl.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import sys
22
from . import Context
3+
from .require import Require
34

45
def main():
56
ctx = Context()
7+
Require(ctx, ".")
68

79
print("QuickJS REPL")
810
eof_key = "Ctrl+Z" if sys.platform == "win32" else "Ctrl+D"

src/quickjs_runtime/require.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import os
2+
# from . import Context <-- Removed to avoid circular import
3+
4+
class Require:
5+
"""
6+
Implements a CommonJS-style 'require' mechanism for QuickJS.
7+
"""
8+
def __init__(self, ctx, base_path: str = "."):
9+
"""
10+
Initialize the Require mechanism.
11+
12+
:param ctx: The Context instance to attach to.
13+
:param base_path: The root path for resolving modules (default: current working directory).
14+
"""
15+
self.ctx = ctx
16+
self.base_path = os.path.abspath(base_path)
17+
self._setup()
18+
19+
def _setup(self):
20+
def _read_file(path):
21+
try:
22+
# Normalize path for OS
23+
path = os.path.normpath(path)
24+
with open(path, "r", encoding="utf-8") as f:
25+
return f.read()
26+
except Exception as e:
27+
raise RuntimeError(f"Cannot read file {path}: {e}")
28+
29+
def _resolve(current_dir, module_name):
30+
# current_dir is where the calling module resides (or "." for root)
31+
# If current_dir is ".", use base_path
32+
if current_dir == ".":
33+
start_dir = self.base_path
34+
else:
35+
start_dir = os.path.normpath(current_dir)
36+
37+
if module_name.startswith("."):
38+
target = os.path.join(start_dir, module_name)
39+
else:
40+
# Absolute or relative to base_path (simple node_modules simulation could go here)
41+
target = os.path.join(self.base_path, module_name)
42+
43+
# Add .js if missing
44+
if not target.endswith(".js"):
45+
target += ".js"
46+
47+
target = os.path.abspath(target)
48+
if not os.path.exists(target):
49+
raise RuntimeError(f"Module not found: {module_name} (at {target})")
50+
51+
# Return with forward slashes for JS consistency
52+
return target.replace(os.sep, "/")
53+
54+
self.ctx.set("__py_require_read", _read_file)
55+
self.ctx.set("__py_require_resolve", _resolve)
56+
57+
# Init cache if not exists
58+
self.ctx.eval("if (!globalThis.__require_cache) globalThis.__require_cache = {};")
59+
60+
# Define require factory and install root require
61+
loader_script = """
62+
(function() {
63+
var read = __py_require_read;
64+
var resolve = __py_require_resolve;
65+
var cache = globalThis.__require_cache;
66+
67+
function make_require(current_dir) {
68+
return function require(module_name) {
69+
// console.log("Resolving", module_name, "from", current_dir);
70+
var full_path = resolve(current_dir, module_name);
71+
// console.log("Resolved to", full_path);
72+
73+
if (cache[full_path]) {
74+
return cache[full_path].exports;
75+
}
76+
77+
var content = read(full_path);
78+
79+
// CommonJS wrapper
80+
// We wrap in a function to provide scope
81+
var wrapper_code = "(function(exports, require, module, __filename, __dirname) { " + content + "\\n})";
82+
83+
// console.log("Loading:", full_path);
84+
85+
var compiled_wrapper;
86+
try {
87+
compiled_wrapper = eval(wrapper_code);
88+
} catch (e) {
89+
throw new Error("Syntax error in " + full_path + ": " + e.message);
90+
}
91+
92+
var module = { exports: {} };
93+
cache[full_path] = module;
94+
95+
var new_dir = full_path.substring(0, full_path.lastIndexOf('/'));
96+
97+
try {
98+
compiled_wrapper(module.exports, make_require(new_dir), module, full_path, new_dir);
99+
} catch (e) {
100+
// Clean up cache on error? Maybe not, to avoid infinite loops if cyclic?
101+
// But usually we want to fail hard.
102+
throw e;
103+
}
104+
105+
return module.exports;
106+
};
107+
}
108+
109+
// Install root require if not present
110+
if (!globalThis.require) {
111+
globalThis.require = make_require(".");
112+
}
113+
})();
114+
"""
115+
self.ctx.eval(loader_script)

tests/test_require.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import os
2+
import pytest
3+
from quickjs_runtime import Context
4+
from quickjs_runtime.require import Require
5+
6+
def test_require_simple(tmp_path):
7+
# Create a dummy module in a temporary directory
8+
module_file = tmp_path / "my_module.js"
9+
module_file.write_text("exports.add = function(a, b) { return a + b; };", encoding="utf-8")
10+
11+
ctx = Context()
12+
# Initialize require with the temp directory as base
13+
Require(ctx, str(tmp_path))
14+
15+
res = ctx.eval("var m = require('./my_module'); m.add(10, 20);")
16+
assert res == 30
17+
18+
def test_require_nested(tmp_path):
19+
# Create a nested structure
20+
# main.js -> utils/math.js
21+
22+
(tmp_path / "utils").mkdir()
23+
(tmp_path / "utils" / "math.js").write_text("exports.square = function(x) { return x * x; };", encoding="utf-8")
24+
(tmp_path / "main.js").write_text("""
25+
var math = require('./utils/math');
26+
exports.calc = function(x) { return math.square(x) + 1; };
27+
""", encoding="utf-8")
28+
29+
ctx = Context()
30+
Require(ctx, str(tmp_path))
31+
32+
res = ctx.eval("var main = require('./main'); main.calc(5);")
33+
assert res == 26
34+
35+
def test_require_not_found():
36+
ctx = Context()
37+
Require(ctx, ".")
38+
with pytest.raises(RuntimeError, match="Module not found"):
39+
ctx.eval("require('./non_existent_module')")
40+
41+
def test_require_cache(tmp_path):
42+
module_file = tmp_path / "counter.js"
43+
module_file.write_text("""
44+
if (!globalThis.counter) globalThis.counter = 0;
45+
globalThis.counter++;
46+
exports.get = function() { return globalThis.counter; };
47+
""", encoding="utf-8")
48+
49+
ctx = Context()
50+
Require(ctx, str(tmp_path))
51+
52+
# First require should increment counter
53+
ctx.eval("var c1 = require('./counter');")
54+
assert ctx.eval("c1.get()") == 1
55+
56+
# Second require should use cache, so counter should NOT increment
57+
ctx.eval("var c2 = require('./counter');")
58+
assert ctx.eval("c2.get()") == 1

0 commit comments

Comments
 (0)