FEATURE: expose V8 ES Module API via Context#compile_module#421
Open
ursm wants to merge 1 commit into
Open
Conversation
ursm
added a commit
to ursm/mini_racer
that referenced
this pull request
May 30, 2026
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ursm
added a commit
to ursm/mini_racer
that referenced
this pull request
May 30, 2026
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Implements rubyjs#412. Adds first-class ES module support to MiniRacer::Context so embedders can run import/export code that the existing eval / compile paths reject. Returns Module Namespace Objects, drives the static-import graph through a Ruby-side resolver, exposes import.meta.url, and handles JS `import(...)` via a Context-level resolver setter. API surface: - Context#compile_module(source, filename:) -> MiniRacer::Module (bound to the Context; filename is also exposed to JS as import.meta.url) - Module#instantiate { |specifier, referrer_url| ... } walks the static import graph; the resolver returns another MiniRacer::Module. Imports can be compiled lazily from inside the block. - Module#evaluate runs the module body and returns the evaluation result - Module#namespace -> Hash representation of the Module Namespace Object - Module#status -> :uninstantiated|:instantiating|:instantiated|:evaluating |:evaluated|:errored - Module#dispose / Module#disposed? for eager handle release - Context#dynamic_import_resolver = ->(spec, ref) { ... } for JS `import(...)`; the resolver returns a Module that has been at least instantiated. The callback drives Module::Evaluate and a microtask checkpoint, then resolves the JS Promise with the namespace. Set nil to reject. Use Context#perform_microtask_checkpoint to drain after. Known constraints: - Top-level await is not supported. evaluate raises if the evaluation promise stays pending after the microtask drain; the dynamic import callback rejects with a descriptive Error. - Re-entrant cyclic dynamic import (kEvaluating at callback time) is rejected explicitly rather than silently resolving with a TDZ namespace. - Resolvers receive 2 args; explicit lambdas with arity 1 raise ArgumentError. Use |spec, _| or a Proc. - Concurrent Module#instantiate on the same Context races on the internal resolver slot, same single-threaded-per-Context expectation as the rest of the API. - TruffleRuby shim raises NotImplementedError from Context#compile_module and Context#dynamic_import_resolver= (accepting nil as a no-op so ` ||= ` style code doesn't crash); MiniRacer::Module is stubbed so is_a? / rescue work cross-engine. Implementation: - New wire-protocol tags O/I/V/N/U/Z plus rendezvous markers 'm' (static resolver) and 'd' (dynamic import). - New v8 callbacks: ResolveModuleCallback, SetHostInitializeImportMetaObject, SetHostImportModuleDynamicallyCallback. - State::modules holds ModuleEntry { v8::Global<Module>, std::string filename } keyed by handle id. Global (not Persistent) so Module#dispose actually frees the handle; filenames cached because UnboundModuleScript doesn't expose ScriptOrigin's resource name. - Cross-Context Modules returned by either resolver are rejected explicitly (handle ids restart at 1 per Context). - Length-aware NewFromUtf8 so embedded NULs in filenames survive; explicit EscapableHandleScope so the returned Local<Module> outlives the callback. - Module::Evaluate gated on >= kInstantiated to avoid V8 ApiCheck abort. ~40 regression tests; the dispose test creates+disposes 1000 modules and asserts heap growth stays bounded (verified 1.5MB -> 0 with Global). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ursm
added a commit
to ursm/mini_racer
that referenced
this pull request
May 30, 2026
Combines feature/module-api (PR rubyjs#421) with PR rubyjs#413's CachedData work for Discourse smoke-testing. Experimental — not for merge. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements #412. Adds first-class ES module support to
MiniRacer::Contextso embedders can run
import/exportcode that the existingeval/compilepaths reject. Returns Module Namespace Objects, drives thestatic-import graph through a Ruby-side resolver, exposes
import.meta.url, and handles JSimport(...)via a Context-levelresolver setter.
API surface
Context#compile_module(source, filename:)→MiniRacer::Moduleboundto the Context;
filenameis also exposed to JS asimport.meta.urlModule#instantiate { |specifier, referrer_url| ... }walks the staticimport graph; the resolver must return another
MiniRacer::Module.Imports can be compiled lazily from inside the block.
Module#evaluateruns the module body and returns the evaluation resultModule#namespace→ Hash representation of the Module Namespace ObjectModule#status→:uninstantiated|:instantiating|:instantiated|
:evaluating|:evaluated|:erroredModule#dispose/Module#disposed?— eager handle release (V8Global<Module>::Reset(), not the default-traitsPersistentno-op)Context#dynamic_import_resolver = ->(spec, ref) { ... }for JSimport(...)expressions. The resolver returns a Module that has beenat least instantiated; the callback drives
Module::Evaluateand aPerformMicrotaskCheckpointso body-scheduled microtasks settle, thenresolves the JS Promise with the namespace. Set
nilto reject alldynamic imports. Drain via
Context#perform_microtask_checkpointtoobserve the result in JS
.then/await.Notes / known constraints
evaluateraises if the module'sevaluation promise stays pending after the microtask drain; the dynamic
import callback rejects with a descriptive Error.
kEvaluatingat callback time) isrejected explicitly rather than silently resolving with a TDZ-laden
namespace.
ArgumentError; use|spec, _|or a Proc.Module#instantiatecalls on the same Context race on theinternal resolver slot — same single-threaded-per-Context expectation
as the rest of the API.
NotImplementedErrorfromContext#compile_moduleand
Context#dynamic_import_resolver=(acceptingnilas a no-op so||=style code doesn't crash);MiniRacer::Moduleis stubbed sois_a?/rescuework cross-engine.Implementation
O(compile_module),I(instantiate),V(evaluate),
N(namespace),U(status),Z(dispose), plus rendezvousmarkers
m(static resolver) andd(dynamic import)ResolveModuleCallback+SetHostInitializeImportMetaObjectCallbackSetHostImportModuleDynamicallyCallbackState::modulesholdsModuleEntry { v8::Global<Module>, std::string filename }keyed by handle id — filenames are cached because V8's
UnboundModuleScriptdoesn't expose the
ScriptOriginresource name. Linear scan today; canswitch to identity-hash side map if a real workload measures it.
(handle ids restart at 1 per Context, so the previous "id won't match"
defense was unreliable)
asserts heap growth stays bounded (verified 1.5MB → 0 with
Global).Test plan
bundle exec rake test— 149 runs / 259 assertions / 0 failuresDiscourse's homepage_spec; passes in 24.7s.
🤖 Generated with Claude Code