Skip to content

FEATURE: expose V8 ES Module API via Context#compile_module#421

Open
ursm wants to merge 1 commit into
rubyjs:mainfrom
ursm:feature/module-api
Open

FEATURE: expose V8 ES Module API via Context#compile_module#421
ursm wants to merge 1 commit into
rubyjs:mainfrom
ursm:feature/module-api

Conversation

@ursm
Copy link
Copy Markdown
Contributor

@ursm ursm commented May 30, 2026

Summary

Implements #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.

ctx = MiniRacer::Context.new

dep  = ctx.compile_module("export const base = 10", filename: 'dep.js')
main = ctx.compile_module(<<~JS, filename: 'main.js')
  import { base } from 'dep'
  export const doubled = base * 2
JS

main.instantiate {|specifier, referrer_url| dep }  # called once per import
dep.evaluate
main.evaluate

main.namespace  # => {"doubled" => 20}

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 must return 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? — eager handle release (V8
    Global<Module>::Reset(), not the default-traits Persistent no-op)
  • Context#dynamic_import_resolver = ->(spec, ref) { ... } for JS
    import(...) expressions. The resolver returns a Module that has been
    at least instantiated; the callback drives Module::Evaluate and a
    PerformMicrotaskCheckpoint so body-scheduled microtasks settle, then
    resolves the JS Promise with the namespace. Set nil to reject all
    dynamic imports. Drain via Context#perform_microtask_checkpoint to
    observe the result in JS .then / await.
ctx.dynamic_import_resolver = ->(spec, _ref) { cache.fetch(spec) }
ctx.eval(%(import('dep').then(ns => globalThis.r = ns.x)), filename: 'caller.js')
ctx.perform_microtask_checkpoint
ctx.eval('globalThis.r')  # => 42

Notes / known constraints

  • Top-level await is not supported. evaluate raises if the module's
    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-laden
    namespace.
  • Resolvers receive 2 args. Explicit lambdas with arity 1 will raise
    ArgumentError; use |spec, _| or a Proc.
  • Concurrent Module#instantiate calls on the same Context race 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 (compile_module), I (instantiate), V
    (evaluate), N (namespace), U (status), Z (dispose), plus rendezvous
    markers m (static resolver) and d (dynamic import)
  • New v8 callbacks: ResolveModuleCallback + SetHostInitializeImportMetaObjectCallback
    • SetHostImportModuleDynamicallyCallback
  • State::modules holds ModuleEntry { v8::Global<Module>, std::string filename }
    keyed by handle id — filenames are cached because V8's UnboundModuleScript
    doesn't expose the ScriptOrigin resource name. Linear scan today; can
    switch to identity-hash side map if a real workload measures it.
  • Cross-Context Modules returned by either resolver are rejected explicitly
    (handle ids restart at 1 per Context, so the previous "id won't match"
    defense was unreliable)
  • ~40 regression tests; the dispose test creates+disposes 1000 modules and
    asserts heap growth stays bounded (verified 1.5MB → 0 with Global).

Test plan

🤖 Generated with Claude Code

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 ursm force-pushed the feature/module-api branch from 96488bf to cb93c57 Compare May 30, 2026 13:36
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant