module: expose JS-callable page-module execution (importmap + prefetched sources + moduleRun) - #413
Closed
natureglass wants to merge 1 commit into
Closed
Conversation
…d-source registry + JS-callable moduleRun nx.js has had a full V8-backed ES module system in source/module.cc since TooTallNate#356 (native ES module import resolution), used by nx_run_entry_module for the runtime's own bundle: ScriptCompiler::CompileModule + Module::InstantiateModule + Module::Evaluate, with SetHostInitializeImportMetaObjectCallback + SetHostImportModuleDynamicallyCallback wired for import.meta.url and dynamic import(). The gap this closes: that machinery is not reachable from JS. Embedders that want to execute a page-shaped <script type="module"> — a page renderer, a REPL, arbitrary user-supplied module code — have to either ship a userland loader (SystemJS et al) and forfeit V8's native module semantics + compile cache + module identity, or pre-bundle everything into one entrypoint and reboot the isolate per module. This PR closes the gap with a minimal surface: four JS-callable functions on the $ init object (also published as globalThis.nxjsPageModules for embedders whose code loads after the runtime captures + deletes $), one resolver extension that adds importmap fallback for bare specifiers, and one alternate source lookup path for URL schemes the engine has no fopen access to. source/module.cc: - g_importmaps: pageBase -> (specifier -> resolved target URL). Targets stored already-resolved against pageBase; lookup is O(1) and never re-parses. - g_prefetch_sources: url -> source text. Consulted by load_module before fopen. Lets an embedder execute modules over schemes the engine can't reach directly (brewser://, http(s)://) once its own fetch() has read them. - g_module_page_base: url -> pageBase. Threaded through load_module so a child module inherits its parent's page scope; consulted by resolve_module_callback + dynamic_import_callback to find the right importmap from a referrer's identity. - resolve_specifier_with_map: layered on resolve_specifier — direct URL parse first (browser importmap spec: only bare specifiers consult the map), fall through to g_importmaps. Supports both exact-match and packages-via-trailing-slash prefix match with longest-key-wins per html.spec.whatwg.org. - load_module gains optional page_base (default ""). Existing callers (nx_run_entry_module, filesystem dynamic import) pass empty and are unaffected. - Four JS-callable functions in nx_module_bindings: moduleSetImportmap(pageBase, jsonText) moduleSetSource(url, sourceText) moduleRun(source, url, pageBase): Promise<namespace> moduleClearPage(pageBase) plus identical methods on globalThis.nxjsPageModules (DontEnum | DontDelete, writable). moduleRun chains through top-level await via eval_promise.then(() => ns) when pending. - nx_modules_teardown clears the three new maps alongside the existing cache + urls + entrypoint URL. source/module.h: declares nx_module_bindings + updates the header block comment to document that page-level modules join the pre-existing filesystem-only entrypoint flow. source/main.cc: one line inside build_init_object, right after nx_init_window, calls nx_module_bindings(iso, init_obj). Preserves: - Entrypoint module flow is unchanged. nx_run_entry_module still passes no page scope. load_module still falls through to fopen when there's no prefetched source. resolve_specifier_with_map degrades to resolve_specifier when the page scope is empty. - Dynamic import() from a page module inherits the page scope. - import.meta.url is populated for page modules (same register_module path; init_import_meta reads their URL from g_module_urls unchanged). - Cycles resolve (register_module runs before InstantiateModule). - Top-level await is awaited by the caller. Intentionally not in this PR: - importmap "scopes" section. Parser accepts and ignores. Common use cases (single-page demos, Three.js ecosystem) only touch "imports". ~15 LOC of nested-map lookup; happy to fold in if reviewers want. - v8::SyntheticModule for host-provided modules (moduleSetSynthetic for import { X } from 'nx:foo'). ~50 LOC; distinct capability with its own review surface. Follow-up. - Async C++->JS fetch callback. JS-drives-fetch is a deliberate choice — keeps the engine synchronous, avoids a new cross-language async boundary, reuses the embedder's already-working fetch(). Diff: source/module.cc: +425 / -24 source/module.h: +16 / -2 source/main.cc: +1 / -0 No new source files. No Makefile changes. No new dependencies (ada was already in use; v8::JSON::Parse is standard V8). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Contributor
|
@natureglass is attempting to deploy a commit to the TooTallNate's Team Team on Vercel. A member of the Team first needs to authorize it. |
Contributor
📝 Runtime Type Changes✅ No changes to the public TypeScript API surface. |
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.
Background
nx.js has had a full V8-backed ES module system in
source/module.ccsince the V8 migration. It's used bynx_run_entry_modulefor the runtime's own bundle:ScriptCompiler::CompileModule+Module::InstantiateModule+Module::Evaluate, withSetHostInitializeImportMetaObjectCallback+SetHostImportModuleDynamicallyCallbackwired forimport.meta.urland dynamicimport(). Top-level await is chained. Cycles resolve. It works.The gap
This machinery is not reachable from JS. Embedders that want to execute a page-shaped
<script type="module">— either because they're rendering HTML pages, or because they want to run arbitrary user-supplied module code at runtime, or because they're building a REPL, or because they need to run a test file that imports fixtures — currently can't. They have to either:eval— and forfeit V8's native module semantics, its compile cache, its module identity guarantees, and this file's dynamic-import integration.Neither is great. This PR closes the gap with a minimal surface: four JS-callable functions on the
$bridge object, one resolver extension that adds importmap fallback for bare specifiers, and one alternate source lookup path for URL schemes the engine has nofopenaccess to.The new surface
Attached both to the
$init object (nx.js house style for engine bindings) and to a durableglobalThis.nxjsPageModulesnamespace (the entry point downstream embedders actually use, since$is captured + deleted at nx.js runtime init):nxjsPageModulesis registered asDontEnum | DontDeleteso it stays out offor…in/Object.keys(globalThis), is non-deletable, but remains writable in case an embedder wants to wrap or proxy it. The runtime never touches this global.Usage sketch
The engine does not fetch. The engine does not walk imports pre-instantiate. The engine's contract is only: given an importmap + a source registry + an entry, run V8's real module machinery under spec-conformant semantics. This split keeps the C++ delta small, keeps async I/O on the JS side where
fetch()already lives, and preserves the engine's compile cache and module identity for cross-graph deduplication.What this preserves
nx_run_entry_modulestill passes no page scope.load_modulestill falls through tofopenwhen there's no prefetched source.resolve_specifier_with_mapdegrades toresolve_specifierwhen the page scope is empty. Every existing embedder that only loads a single entrypoint sees zero behavioral change.import()from a page module inherits the page scope. The existingdynamic_import_callbackwas extended with the same page-base-aware resolver + prefetch check.import('three')from an inline module works the same asimport * as THREE from 'three'.import.meta.urlis populated for page modules. They go through the sameregister_modulepath;init_import_metareads their URL fromg_module_urlsunchanged.register_moduleruns beforeInstantiateModule, matching the existing pattern.moduleRunchains the evaluation promise into the returned Promise via.then(() => ns)when pending.What's intentionally not in this PR
v8::SyntheticModulefor host-provided modules. Would slot in asmoduleSetSynthetic(url, exportsObject)forimport { X } from 'nx:foo'patterns. ~50 LOC; leaving for a follow-up PR because it's a distinct capability with its own review surface (export-name discovery, evaluation callback shape).fetch(). If a future use case genuinely needs the engine to initiate a load (dynamic import of a URL not pre-scanned), a JS-side registered fetcher wrapped throughnx_queue_asyncis the natural extension.Diff summary
No new source files. No Makefile changes. No new dependencies (
adawas already in use;v8::JSON::Parseis standard V8). No changes to the existing entrypoint-module contract or to any other binding.