Skip to content

fix(core): add SystemJS fallback for ESM server bundle execution - #8290

Draft
SyMind wants to merge 11 commits into
mainfrom
systemjs-runner
Draft

fix(core): add SystemJS fallback for ESM server bundle execution#8290
SyMind wants to merge 11 commits into
mainfrom
systemjs-runner

Conversation

@SyMind

@SyMind SyMind commented Aug 17, 2026

Copy link
Copy Markdown
Member

Summary

This PR adds a SystemJS-based fallback for evaluating ESM server bundles when Node.js does not expose vm.SourceTextModule.

The server runner now selects the execution strategy based on the bundle format and runtime capabilities:

Bundle output Runtime capability Runner
CommonJS Any supported Node.js runtime CommonJsRunner
ESM vm.SourceTextModule available EsmRunner
ESM vm.SourceTextModule unavailable SystemJsRunner

This allows Rsbuild to execute ESM server output without requiring --experimental-vm-modules, while preserving the existing ESM runner where it is available.

Design

SystemJS transformation

Bundle files are transformed lazily from ESM to anonymous System.register modules using Rspack's bundled SWC transform.

The transform:

  • disables external SWC configuration;
  • emits SystemJS modules and source maps;
  • supports static and dynamic imports;
  • temporarily targets ES5 so destructuring assignments are lowered before the SystemJS transform, avoiding invalid assignment targets produced by the current SWC implementation.

No transformed bundle snapshot or persistent transform cache is introduced. Source is read through the existing runner options when a bundle module is registered.

Module registration and lifecycle

SystemJsEvaluator implements the runtime required by SWC-generated System.register output.

Each module is represented by a SystemJsModuleNode that tracks:

  • its registration and namespace;
  • resolved dependencies and importers;
  • live export slots;
  • registration, instantiation, and evaluation promises;
  • its current lifecycle state and failure reason.

The evaluator captures one anonymous System.register call per bundle module and executes modules in three phases:

  1. Register – transform the bundle file and capture its dependencies and declaration.
  2. Instantiate – construct the dependency graph, create namespaces, and connect dependency setters.
  3. Evaluate – execute dependencies before importers while handling cycles and async module execution.

ESM semantics

The evaluator implements the ESM behavior required by generated server bundles:

  • module namespaces use Symbol.toStringTag = 'Module';
  • exports are exposed through getters;
  • updated exports notify importer setters to preserve live bindings;
  • cyclic module graphs do not deadlock during evaluation;
  • import.meta.url is derived from the absolute bundle module path;
  • dynamic imports reuse the same bundle-module resolution path;
  • static external imports are checked for missing named exports and report a native ESM-style SyntaxError.

Module resolution

Bundle module IDs are normalized as absolute paths and must remain inside the configured output directory.

Dependencies are divided into two categories:

  • Bundle modules are identified through isBundleOutput, loaded through readFileSync, transformed, and evaluated by SystemJsEvaluator.
  • External modules are resolved relative to the importing bundle file using Rspack's resolver with Node ESM conditions, then loaded through native import().

Resolving externals from the bundle importer is important for applications whose dependencies are installed next to the application rather than next to @rsbuild/core.

Error handling and source maps

Runner errors use the [rsbuild:runner] prefix and preserve the original error through cause.

The SWC transform emits an inline source map. Runtime frames produced by the SystemJS transform are mapped back to their corresponding bundle locations before the error is propagated.

Missing static external exports use an ESM-compatible error message:

[rsbuild:runner] The requested module '<module>' does not provide an export named '<name>'

Why not module.registerHooks()

A native ESM loader hook could serve bundle output directly from memfs, but it cannot provide safe cache invalidation across rebuilds:

  • module.registerHooks() was added in Node.js 22.15.0, while @rsbuild/core supports Node.js ^20.19.0 || >=22.12.0.
  • Node.js resolves and caches ESM by URL in a cache separate from require.cache. Reusing a stable URL such as /dist/index.js after a rebuild returns the previous module without calling the load hook again.
  • Adding a build ID to the URL, such as /${timestamp}/dist/index.js, loads the latest source but creates a new cached module graph on every rebuild. Node.js exposes no public API for evicting these ESM cache entries, so old module namespaces and their retained state remain alive and memory usage can grow throughout a long-running watch session.

SystemJsEvaluator instead owns a runner-scoped module cache. Replacing the runner releases the whole module graph for garbage collection, while still allowing bundle source to be read from memfs.

References: Node.js module.registerHooks(), ES modules are cached as URLs, and ESM uses a separate cache.

Related Links

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying rsbuild with  Cloudflare Pages  Cloudflare Pages

Latest commit: 4de1ab5
Status: ✅  Deploy successful!
Preview URL: https://39df365c.rsbuild-v2.pages.dev
Branch Preview URL: https://systemjs-runner.rsbuild-v2.pages.dev

View logs

@SyMind

SyMind commented Aug 18, 2026

Copy link
Copy Markdown
Member Author

@codex review

@SyMind
SyMind marked this pull request as ready for review August 18, 2026 08:18

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ac9db0accd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/core/src/server/runner/systemJs.ts Outdated
@SyMind

SyMind commented Aug 18, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2159a4aec0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/core/src/server/runner/systemJs.ts Outdated
Comment thread packages/core/src/server/runner/systemJsTransform.ts Outdated
@SyMind

SyMind commented Aug 18, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9a94c3b5eb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/core/src/server/runner/systemJs.ts
Comment on lines +430 to +436
setter(
this.#processImport(
namespace,
specifier,
registration.importMetadata[index],
),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve live bindings imported from external modules

When an external ESM mutates an exported let, its namespace getter updates, but this setter is invoked only once and SWC-generated setters copy the current property value into the bundle's local binding. For example, a bundle that imports { ready, mark }, calls external mark() to set ready = true, and then reads ready will still see false; external dependencies need a mechanism that preserves live imported bindings rather than a one-time setter call.

Useful? React with 👍 / 👎.

Comment on lines +576 to +579
for (const dependency of moduleNode.dependencies) {
if (!nextAncestors.has(dependency.id)) {
await this.#evaluateModule(dependency, nextAncestors);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Start independent async dependencies before awaiting them

When sibling bundle chunks both use top-level await, awaiting each recursive evaluation inside this loop prevents the second dependency from starting until the first has completely settled. Native ESM starts independent async siblings before waiting for their completion, so this fallback serializes their latency and changes observable execution ordering; schedule dependency evaluations first and await their completion collectively while retaining cycle handling.

Useful? React with 👍 / 👎.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new execution path for ESM server bundles when the runtime does not support vm.SourceTextModule, by evaluating bundles via a SystemJS (System.register) transform + runtime. This enhances compatibility across supported Node.js versions while keeping the existing ESM VM-based runner when available.

Changes:

  • Make the runner factory async and select between CommonJsRunner, EsmRunner, and the new SystemJsRunner based on bundle format and vm.SourceTextModule availability.
  • Introduce SystemJsRunner/SystemJsEvaluator plus an SWC-based transformToSystemJs helper for lazy ESM→SystemJS transformation with inline source maps.
  • Add unit tests covering import.meta behavior, source-map stack mapping, and missing static external export diagnostics.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/core/tests/systemJsRunner.test.ts Adds tests validating SystemJS runner behavior (import.meta, source maps, missing exports).
packages/core/src/server/runner/type.ts Updates runner factory interface to return Promise<Runner>.
packages/core/src/server/runner/systemJsTransform.ts Adds SWC-based ESM→SystemJS transform with inline source map handling.
packages/core/src/server/runner/systemJs.ts Implements SystemJS evaluator/runtime, module lifecycle, and external resolution.
packages/core/src/server/runner/index.ts Updates runner selection logic to include CommonJS and SystemJS fallback, and awaits factory creation.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/core/src/server/runner/systemJsTransform.ts
Comment thread packages/core/src/server/runner/systemJs.ts
Comment thread packages/core/tests/systemJsRunner.test.ts
@SyMind
SyMind marked this pull request as draft August 18, 2026 10:01
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.

2 participants