Skip to content

module: expose JS-callable page-module execution (importmap + prefetched sources + moduleRun) - #413

Closed
natureglass wants to merge 1 commit into
TooTallNate:mainfrom
natureglass:upstream-pr/modules-page-level
Closed

module: expose JS-callable page-module execution (importmap + prefetched sources + moduleRun)#413
natureglass wants to merge 1 commit into
TooTallNate:mainfrom
natureglass:upstream-pr/modules-page-level

Conversation

@natureglass

Copy link
Copy Markdown
Contributor

Background

nx.js has had a full V8-backed ES module system in source/module.cc since the V8 migration. It's 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(). 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:

  1. Ship a userland module loader (SystemJS, RequireJS, etc.) — ~30 KB, its own resolver, its own compile pipeline running on top of eval — and forfeit V8's native module semantics, its compile cache, its module identity guarantees, and this file's dynamic-import integration.
  2. Pre-bundle everything into a single entrypoint and reboot the isolate for every module they want to run.

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 no fopen access to.

The new surface

Attached both to the $ init object (nx.js house style for engine bindings) and to a durable globalThis.nxjsPageModules namespace (the entry point downstream embedders actually use, since $ is captured + deleted at nx.js runtime init):

// Register an importmap for a page scope. Merges on repeat calls (last
// write wins per specifier). Silently no-ops on malformed JSON.
$.moduleSetImportmap(pageBase: string, mapJson: string): void
globalThis.nxjsPageModules.setImportmap(pageBase, mapJson)

// Register source text for a URL. The resolver consults this map BEFORE
// falling through to fopen. Lets an embedder execute modules over URL
// schemes the engine can't reach directly (http(s)://, custom schemes)
// as long as the embedder's own fetch() reached them first.
$.moduleSetSource(url: string, source: string): void
globalThis.nxjsPageModules.setSource(url, source)

// Compile + instantiate + evaluate `source` as a module identified by
// `url`, resolving bare specifiers via `pageBase`'s importmap. Returns
// a Promise mirroring the evaluation promise: fulfills with the module
// namespace, rejects on any compile/instantiate/evaluate failure,
// chained through top-level await.
$.moduleRun(source: string, url: string, pageBase: string): Promise<any>
globalThis.nxjsPageModules.run(source, url, pageBase)

// Purge everything tagged with this page scope (importmap, module cache,
// prefetched sources, page-base tagging). Call on page navigation.
$.moduleClearPage(pageBase: string): void
globalThis.nxjsPageModules.clearPage(pageBase)

nxjsPageModules is registered as DontEnum | DontDelete so it stays out of for…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

const pageBase = 'app://apps/foo/index.html';

// From the HTML: <script type="importmap">{"imports":{"three":"./assets/three.module.js"}}</script>
$.moduleSetImportmap(pageBase, importmapJsonText);

// Walk the entry module's static imports (regex or ESTree scan),
// fetch each URL via fetch(), register with the engine.
for (const [url, source] of await prefetchGraph(entryUrl, pageBase)) {
  $.moduleSetSource(url, source);
}

// From the HTML: <script type="module">import * as THREE from 'three'; ...</script>
await $.moduleRun(inlineSource, `${pageBase}#inline-0`, pageBase);

// On navigation:
$.moduleClearPage(pageBase);

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

  • 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. Every existing embedder that only loads a single entrypoint sees zero behavioral change.
  • Dynamic import() from a page module inherits the page scope. The existing dynamic_import_callback was extended with the same page-base-aware resolver + prefetch check. import('three') from an inline module works the same as import * as THREE from 'three'.
  • import.meta.url is populated for page modules. They go through the same register_module path; init_import_meta reads their URL from g_module_urls unchanged.
  • Cycles resolve. register_module runs before InstantiateModule, matching the existing pattern.
  • Top-level await is awaited by the caller. moduleRun chains the evaluation promise into the returned Promise via .then(() => ns) when pending.

What's intentionally not in this PR

  • v8::SyntheticModule for host-provided modules. Would slot in as moduleSetSynthetic(url, exportsObject) for import { 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).
  • Async C++→JS fetch callback. The JS-drives-fetch design is a deliberate choice — it keeps the engine synchronous, avoids a new cross-language async boundary, and reuses the embedder's already-working 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 through nx_queue_async is the natural extension.

Diff summary

 source/module.cc | +425 / -24  (state + resolver ext + 4 bindings + durable global + teardown)
 source/module.h  |  +16 /  -2  (declaration + updated header block comment)
 source/main.cc   |   +1 /  -0  (nx_module_bindings call in build_init_object)
 3 source files changed

No new source files. No Makefile changes. No new dependencies (ada was already in use; v8::JSON::Parse is standard V8). No changes to the existing entrypoint-module contract or to any other binding.

…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>
@changeset-bot

changeset-bot Bot commented Jul 11, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: a83ee57

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@vercel

vercel Bot commented Jul 11, 2026

Copy link
Copy Markdown
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.

@github-actions

Copy link
Copy Markdown
Contributor

📝 Runtime Type Changes

✅ No changes to the public TypeScript API surface.

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