Skip to content

feat: support ModuleType:Copy#8407

Merged
graphite-app[bot] merged 1 commit intomainfrom
02-21-feat_support_moduletype_copy_
Feb 21, 2026
Merged

feat: support ModuleType:Copy#8407
graphite-app[bot] merged 1 commit intomainfrom
02-21-feat_support_moduletype_copy_

Conversation

@hyf0
Copy link
Member

@hyf0 hyf0 commented Feb 21, 2026

Summary

Adds ModuleType::Copy — a new module type that copies assets (images, fonts, binaries, etc.) to the output directory and rewrites imports to point at the emitted file.

Configure via moduleTypes:

export default {
  moduleTypes: {
    '.png': 'copy',
    '.woff2': 'copy',
  }
}

How it works

The implementation lives in a new builtin plugin (rolldown_plugin_copy_module) that operates in two phases:

Phase 1: resolve_id

When rolldown encounters an import like import img from './photo.png':

  1. Resolves the specifier to an absolute path
  2. Checks if the extension is registered as a copy module type
  3. Reads the file bytes and emits them as an asset via ctx.emit_file_async() — the FileEmitter hashes the content with XXH3-128 and generates a filename from the configured assetFileNames template (default: assets/[name]-[hash][extname])
  4. Returns a prefixed placeholder ID (__ROLLDOWN_COPY_MODULE__#<reference_id>) marked as external: true

Marking external is the key trick — rolldown won't try to parse or bundle the file, just emit an import statement with the placeholder string.

Phase 2: render_chunk

After chunk code is generated (containing import img from "__ROLLDOWN_COPY_MODULE__#ref123"):

  1. Uses memchr::memmem for SIMD-accelerated search of all placeholder positions
  2. Resolves each reference ID to the final asset filename via ctx.get_file_name()
  3. Computes the relative path from the chunk to the asset
  4. Replaces the placeholder with the real path via MagicString

Result: import img from "./assets/photo-4dj8Fk2L.png"

Changes

  • crates/rolldown_plugin_copy_module/ — new builtin plugin crate
  • crates/rolldown_common/.../module_type.rs — adds ModuleType::Copy variant
  • crates/rolldown/src/utils/apply_inner_plugins.rs — registers the plugin when copy extensions are configured
  • crates/rolldown/src/utils/load_source.rs — handles Copy module type in the load phase
  • crates/rolldown/src/utils/parse_to_ecma_ast.rs — treats Copy modules as empty JS (they're externalized by the plugin)
  • packages/rolldown/src/options/input-options.ts — adds 'copy' to the ModuleType union
  • Tests — two integration tests: basic copy and nested path resolution

🤖 Generated with Claude Code

Closes #6411

Copy link
Member Author

hyf0 commented Feb 21, 2026


How to use the Graphite Merge Queue

Add the label graphite: merge-when-ready to this PR to add it to the merge queue.

You must have a Graphite account in order to use the merge queue. Sign up using this link.

An organization admin has enabled the Graphite Merge Queue in this repository.

Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue.

This stack of pull requests is managed by Graphite. Learn more about stacking.

@hyf0 hyf0 marked this pull request as ready for review February 21, 2026 09:35
Copilot AI review requested due to automatic review settings February 21, 2026 09:35
@netlify
Copy link

netlify bot commented Feb 21, 2026

Deploy Preview for rolldown-rs canceled.

Name Link
🔨 Latest commit e3b4aea
🔍 Latest deploy log https://app.netlify.com/projects/rolldown-rs/deploys/6999bd8617c03f00081e63f6

Copy link
Contributor

Copilot AI left a comment

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 ModuleType::Copy loader to Rolldown (aligned with esbuild “copy” semantics) by introducing a builtin plugin that emits matched modules as assets and rewrites import/require specifiers to the emitted asset paths.

Changes:

  • Extend TS and Rust option/type validation to accept "copy" as a module type.
  • Introduce rolldown_plugin_copy_module and wire it into Rolldown’s builtin plugin pipeline when configured.
  • Add integration tests + snapshot updates covering basic and nested-path copy-module scenarios.

Reviewed changes

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

Show a summary per file
File Description
packages/rolldown/src/utils/validator.ts Allows "copy" in moduleTypes validation.
packages/rolldown/src/options/input-options.ts Extends ModuleTypes union to include "copy".
crates/rolldown_testing/_config.schema.json Updates JSON schema enum to include "copy".
crates/rolldown_plugin_copy_module/src/lib.rs New builtin plugin: resolves copy-modules, emits assets, rewrites chunk code.
crates/rolldown_plugin_copy_module/Cargo.toml Adds new plugin crate manifest.
crates/rolldown_common/src/inner_bundler_options/types/module_type.rs Adds ModuleType::Copy + string conversions/display.
crates/rolldown/src/utils/parse_to_ecma_ast.rs Errors if Copy reaches parsing stage (should be intercepted by plugin).
crates/rolldown/src/utils/load_source.rs Errors if Copy reaches file loading stage (should be intercepted by plugin).
crates/rolldown/src/utils/apply_inner_plugins.rs Registers the copy-module plugin when moduleTypes includes copy.
crates/rolldown/Cargo.toml Adds dependency on the new plugin crate.
Cargo.toml / Cargo.lock Wires new crate into workspace deps/lockfile.
crates/rolldown/tests/** Adds new copy-module fixtures and snapshot expectations.

@github-actions
Copy link
Contributor

github-actions bot commented Feb 21, 2026

Benchmarks Rust

  • target: main(6c8834b)
  • pr: 02-21-feat_support_moduletype_copy_(e3b4aea)
group                                                        pr                                     target
-----                                                        --                                     ------
bundle/bundle@multi-duplicated-top-level-symbol              1.00     72.3±1.99ms        ? ?/sec    1.02     73.5±4.10ms        ? ?/sec
bundle/bundle@multi-duplicated-top-level-symbol-sourcemap    1.04     80.4±2.98ms        ? ?/sec    1.00     77.2±3.12ms        ? ?/sec
bundle/bundle@rome_ts                                        1.01    104.6±4.02ms        ? ?/sec    1.00    103.3±2.51ms        ? ?/sec
bundle/bundle@rome_ts-sourcemap                              1.00    115.5±2.39ms        ? ?/sec    1.00    115.9±3.15ms        ? ?/sec
bundle/bundle@threejs                                        1.05     37.3±0.66ms        ? ?/sec    1.00     35.5±2.22ms        ? ?/sec
bundle/bundle@threejs-sourcemap                              1.01     41.7±0.98ms        ? ?/sec    1.00     41.2±1.10ms        ? ?/sec
bundle/bundle@threejs10x                                     1.02    372.0±7.32ms        ? ?/sec    1.00    365.1±6.55ms        ? ?/sec
bundle/bundle@threejs10x-sourcemap                           1.04    430.4±7.80ms        ? ?/sec    1.00    413.4±3.52ms        ? ?/sec
scan/scan@rome_ts                                            1.00     78.4±1.55ms        ? ?/sec    1.01     79.4±2.16ms        ? ?/sec
scan/scan@threejs                                            1.01     28.2±1.70ms        ? ?/sec    1.00     27.9±1.67ms        ? ?/sec
scan/scan@threejs10x                                         1.00    283.4±5.09ms        ? ?/sec    1.00    284.1±7.83ms        ? ?/sec

@hyf0 hyf0 force-pushed the 02-21-feat_support_moduletype_copy_ branch from 244ebd6 to 82c7e6c Compare February 21, 2026 10:04
Copy link
Member Author

hyf0 commented Feb 21, 2026

Merge activity

  • Feb 21, 12:00 PM UTC: The merge label 'graphite: merge-when-ready' was detected. This PR will be added to the Graphite merge queue once it meets the requirements.
  • Feb 21, 12:00 PM UTC: hyf0 added this pull request to the Graphite merge queue.
  • Feb 21, 12:05 PM UTC: The Graphite merge queue couldn't merge this PR because it was not satisfying all requirements (Failed CI: 'cargo-test (ubuntu-latest) / Cargo Test', 'cargo-test (macos-latest) / Cargo Test', 'cargo-test (windows-latest) / Cargo Test').
  • Feb 21, 12:06 PM UTC: The merge label 'graphite: merge-when-ready' was detected. This PR will be added to the Graphite merge queue once it meets the requirements.
  • Feb 21, 12:06 PM UTC: The merge label 'graphite: merge-when-ready' was removed. This PR will no longer be merged by the Graphite merge queue
  • Feb 21, 2:24 PM UTC: The merge label 'graphite: merge-when-ready' was detected. This PR will be added to the Graphite merge queue once it meets the requirements.
  • Feb 21, 2:24 PM UTC: hyf0 added this pull request to the Graphite merge queue.
  • Feb 21, 2:25 PM UTC: Merged by the Graphite merge queue.

graphite-app bot pushed a commit that referenced this pull request Feb 21, 2026
## Summary

Adds `ModuleType::Copy` — a new module type that copies assets (images, fonts, binaries, etc.) to the output directory and rewrites imports to point at the emitted file.

Configure via `moduleTypes`:

```js
export default {
  moduleTypes: {
    '.png': 'copy',
    '.woff2': 'copy',
  }
}
```

## How it works

The implementation lives in a new builtin plugin (`rolldown_plugin_copy_module`) that operates in two phases:

### Phase 1: `resolve_id`

When rolldown encounters an import like `import img from './photo.png'`:

1. Resolves the specifier to an absolute path
2. Checks if the extension is registered as a copy module type
3. Reads the file bytes and emits them as an asset via `ctx.emit_file_async()` — the `FileEmitter` hashes the content with XXH3-128 and generates a filename from the configured `assetFileNames` template (default: `assets/[name]-[hash][extname]`)
4. Returns a prefixed placeholder ID (`__ROLLDOWN_COPY_MODULE__#<reference_id>`) marked as `external: true`

Marking external is the key trick — rolldown won't try to parse or bundle the file, just emit an import statement with the placeholder string.

### Phase 2: `render_chunk`

After chunk code is generated (containing `import img from "__ROLLDOWN_COPY_MODULE__#ref123"`):

1. Uses `memchr::memmem` for SIMD-accelerated search of all placeholder positions
2. Resolves each reference ID to the final asset filename via `ctx.get_file_name()`
3. Computes the relative path from the chunk to the asset
4. Replaces the placeholder with the real path via MagicString

Result: `import img from "./assets/photo-4dj8Fk2L.png"`

## Changes

- **`crates/rolldown_plugin_copy_module/`** — new builtin plugin crate
- **`crates/rolldown_common/.../module_type.rs`** — adds `ModuleType::Copy` variant
- **`crates/rolldown/src/utils/apply_inner_plugins.rs`** — registers the plugin when copy extensions are configured
- **`crates/rolldown/src/utils/load_source.rs`** — handles `Copy` module type in the load phase
- **`crates/rolldown/src/utils/parse_to_ecma_ast.rs`** — treats `Copy` modules as empty JS (they're externalized by the plugin)
- **`packages/rolldown/src/options/input-options.ts`** — adds `'copy'` to the `ModuleType` union
- **Tests** — two integration tests: basic copy and nested path resolution

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Closes #6411
@graphite-app graphite-app bot force-pushed the 02-21-feat_support_moduletype_copy_ branch from 82c7e6c to 803e775 Compare February 21, 2026 12:01
## Summary

Adds `ModuleType::Copy` — a new module type that copies assets (images, fonts, binaries, etc.) to the output directory and rewrites imports to point at the emitted file.

Configure via `moduleTypes`:

```js
export default {
  moduleTypes: {
    '.png': 'copy',
    '.woff2': 'copy',
  }
}
```

## How it works

The implementation lives in a new builtin plugin (`rolldown_plugin_copy_module`) that operates in two phases:

### Phase 1: `resolve_id`

When rolldown encounters an import like `import img from './photo.png'`:

1. Resolves the specifier to an absolute path
2. Checks if the extension is registered as a copy module type
3. Reads the file bytes and emits them as an asset via `ctx.emit_file_async()` — the `FileEmitter` hashes the content with XXH3-128 and generates a filename from the configured `assetFileNames` template (default: `assets/[name]-[hash][extname]`)
4. Returns a prefixed placeholder ID (`__ROLLDOWN_COPY_MODULE__#<reference_id>`) marked as `external: true`

Marking external is the key trick — rolldown won't try to parse or bundle the file, just emit an import statement with the placeholder string.

### Phase 2: `render_chunk`

After chunk code is generated (containing `import img from "__ROLLDOWN_COPY_MODULE__#ref123"`):

1. Uses `memchr::memmem` for SIMD-accelerated search of all placeholder positions
2. Resolves each reference ID to the final asset filename via `ctx.get_file_name()`
3. Computes the relative path from the chunk to the asset
4. Replaces the placeholder with the real path via MagicString

Result: `import img from "./assets/photo-4dj8Fk2L.png"`

## Changes

- **`crates/rolldown_plugin_copy_module/`** — new builtin plugin crate
- **`crates/rolldown_common/.../module_type.rs`** — adds `ModuleType::Copy` variant
- **`crates/rolldown/src/utils/apply_inner_plugins.rs`** — registers the plugin when copy extensions are configured
- **`crates/rolldown/src/utils/load_source.rs`** — handles `Copy` module type in the load phase
- **`crates/rolldown/src/utils/parse_to_ecma_ast.rs`** — treats `Copy` modules as empty JS (they're externalized by the plugin)
- **`packages/rolldown/src/options/input-options.ts`** — adds `'copy'` to the `ModuleType` union
- **Tests** — two integration tests: basic copy and nested path resolution

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Closes #6411
Copilot AI review requested due to automatic review settings February 21, 2026 14:13
@hyf0 hyf0 force-pushed the 02-21-feat_support_moduletype_copy_ branch from 803e775 to e3b4aea Compare February 21, 2026 14:13
@graphite-app graphite-app bot merged commit e3b4aea into main Feb 21, 2026
46 checks passed
@graphite-app graphite-app bot deleted the 02-21-feat_support_moduletype_copy_ branch February 21, 2026 14:25
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 22 out of 23 changed files in this pull request and generated 2 comments.

Comment on lines +121 to +165
async fn render_chunk(
&self,
ctx: &PluginContext,
args: &HookRenderChunkArgs<'_>,
) -> HookRenderChunkReturn {
// Quick bail: if the code doesn't contain our prefix, nothing to do
if !args.code.contains(PREFIX) {
return Ok(None);
}

let chunk_filename = &args.chunk.filename;
let code = &args.code;
let mut magic_string = MagicString::new(code);
let mut changed = false;

// Use memchr for SIMD-accelerated substring search
let finder = memmem::find_iter(code.as_bytes(), PREFIX.as_bytes());

for abs_pos in finder {
let after_prefix = abs_pos + PREFIX.len();

// Extract ref_id: scan until we hit a quote (", ') or end of string
let rest = &code[after_prefix..];
let ref_end = rest.find(['"', '\'']).unwrap_or(rest.len());
let ref_id = &rest[..ref_end];

if ref_id.is_empty() {
continue;
}

// Resolve the asset filename
let asset_filename = match ctx.get_file_name(ref_id) {
Ok(name) => name,
Err(_) => continue,
};

// Compute relative path from chunk to asset
let relative = compute_relative_path(chunk_filename, &asset_filename);

let end = after_prefix + ref_end;
#[expect(clippy::cast_possible_truncation)]
if magic_string.update(abs_pos as u32, end as u32, relative).is_ok() {
changed = true;
}
}
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

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

render_chunk rewrites every occurrence of the prefix in the full chunk text, without checking context (e.g. that it’s inside an import/require string literal emitted by rolldown). This means any user-authored string/comment containing the prefix would be rewritten too. To make this more robust, consider using an unambiguous sentinel with a terminator (similar to __VITE_ASSET__<ref>__) and validating the reference-id characters / surrounding delimiters before applying replacements.

Copilot uses AI. Check for mistakes.
Comment on lines +111 to +116
// Return a prefixed external ID — the prefix will be rewritten in render_chunk
let placeholder_id: ArcStr = format!("{PREFIX}{reference_id}").into();

Ok(Some(HookResolveIdOutput {
id: placeholder_id,
external: Some(ResolvedExternal::Bool(true)),
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

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

Marking copy modules as external and then rewriting the import specifier in render_chunk leaves the output as import x from "./assets/foo.txt" / require("./assets/foo.txt"). That does not match existing ModuleType::Asset semantics (which bundle a JS module exporting the emitted path string) and will fail in standard JS runtimes because the copied asset isn’t a JS module. Consider generating a virtual JS module instead (e.g. resolve to a \0-prefixed id, implement load to export default import.meta.ROLLUP_FILE_URL_<refId> or similar) and let the existing asset URL rewriting machinery produce the final string path.

Suggested change
// Return a prefixed external ID — the prefix will be rewritten in render_chunk
let placeholder_id: ArcStr = format!("{PREFIX}{reference_id}").into();
Ok(Some(HookResolveIdOutput {
id: placeholder_id,
external: Some(ResolvedExternal::Bool(true)),
// Return a prefixed ID; treat it as an asset module so the bundler
// generates a JS module exporting the asset URL instead of a bare
// import of a non-JS file.
let placeholder_id: ArcStr = format!("{PREFIX}{reference_id}").into();
Ok(Some(HookResolveIdOutput {
id: placeholder_id,
// Let the bundler handle this as an asset module instead of an external.
module_type: Some(ModuleType::Asset),
external: None,

Copilot uses AI. Check for mistakes.
shulaoda added a commit that referenced this pull request Feb 26, 2026
## [1.0.0-rc.6] - 2026-02-26

### 💥 BREAKING CHANGES

- css: remove `css_entry_filenames` , `css_chunk_filenames` and related code (#8402) by @hyf0
- css: drop builtin CSS bundling to explore alternative solutions (#8399) by @hyf0

### 🚀 Features

- rust/data-url: use hash as id for data url modules to prevent long string overhead (#8420) by @hyf0
- validate bundle stays within output dir (#8441) by @sapphi-red
- rust: support `PluginOrder::PinPost` (#8417) by @hyf0
- support `ModuleType:Copy` (#8407) by @hyf0
- expose `ESTree` types from `rolldown/utils` (#8400) by @sapphi-red

### 🐛 Bug Fixes

- incorrect sourcemap when postBanner/postFooter is used with shebang (#8459) by @Copilot
- resolver: disable node_path option to align ESM resolver behavior (#8472) by @sapphi-red
- parse `.js` within `"type": "commonjs"` as ESM for now (#8470) by @sapphi-red
- case-insensitive filename conflict detection for chunk deduplication (#8458) by @Copilot
- prevent inlining CJS exports that are mutated by importers (#8456) by @IWANABETHATGUY
- parse `.cjs` / `.cts` / `.js` within `"type": "commonjs"` as CommonJS (#8455) by @sapphi-red
- plugin/copy-module: correct hooks' priority (#8423) by @hyf0
- plugin/chunk-import-map: ensure `render_chunk_meta` run after users plugin (#8422) by @hyf0
- rust: correct hooks order of `DataUriPlugin` (#8418) by @hyf0
- `jsx.preserve` should also considering tsconfig json preserve (#8324) by @IWANABETHATGUY
- `deferred_scan_data.rs "Should have resolved id: NotFound"` error (#8379) by @sapphi-red
- cli: require value for `--dir`/`-d` and `--file`/`-o` (#8378) by @Copilot
- dev: avoid mutex deadlock caused by inconsistent lock order (#8370) by @sapphi-red

### 🚜 Refactor

- watch: rename TaskStart/TaskEnd to BundleStart/BundleEnd (#8463) by @hyf0
- rust: rename `rolldown_plugin_data_uri` to `rolldown_plugin_data_url` (#8421) by @hyf0
- bindingify-build-hook: extract helper for PluginContextImpl (#8438) by @ShroXd
- give source loading a proper name (#8436) by @IWANABETHATGUY
- ban holding DashMap refs across awaits (#8362) by @sapphi-red

### 📚 Documentation

- add glob pattern usage example to input option (#8469) by @IWANABETHATGUY
- remove `https://rolldown.rs` from links in reference docs (#8454) by @sapphi-red
- mention execution order issue in `output.codeSplitting` docs (#8452) by @sapphi-red
- clarify `output.comments` behavior a bit (#8451) by @sapphi-red
- replace npmjs package links with npmx.dev (#8439) by @Boshen
- reference: add `Exported from` for values / types exported from subpath exports (#8394) by @sapphi-red
- add JSDocs for APIs exposed from subpath exports (#8393) by @sapphi-red
- reference: generate reference pages for APIs exposed from subpath exports (#8392) by @sapphi-red
- avoid pipe character in codeSplitting example to fix broken rendering (#8391) by @IWANABETHATGUY

### ⚡ Performance

- avoid redundant PathBuf allocations in resolve paths (#8435) by @Brooooooklyn
- bump to `sugar_path@2` (#8432) by @hyf0
- use flag-based convergence detection in include_statements (#8412) by @Brooooooklyn

### 🧪 Testing

- execute `_test.mjs` even if `executeOutput` is false (#8398) by @sapphi-red
- add retry to tree-shake/module-side-effects-proxy4 as it is flaky (#8397) by @sapphi-red
- avoid `expect.assertions()` as it is not concurrent test friendly (#8383) by @sapphi-red
- disable `mockReset` option (#8382) by @sapphi-red
- fix flaky failure caused by concurrent resolveId calls (#8381) by @sapphi-red

### ⚙️ Miscellaneous Tasks

- deps: update dependency rollup to v4.59.0 [security] (#8471) by @renovate[bot]
- ai/design: add design doc about watch mode (#8453) by @hyf0
- deps: update oxc resolver to v11.19.0 (#8461) by @renovate[bot]
- ai: introduce progressive spec-driven development pattern (#8446) by @hyf0
- deprecate output.legalComments (#8450) by @sapphi-red
- deps: update dependency oxlint-tsgolint to v0.15.0 (#8448) by @renovate[bot]
- ai: make CLAUDE.md a symlink of AGENTS.md (#8445) by @hyf0
- deps: update rollup submodule for tests to v4.59.0 (#8433) by @sapphi-red
- deps: update test262 submodule for tests (#8434) by @sapphi-red
- deps: update oxc to v0.115.0 (#8430) by @renovate[bot]
- deps: update oxc apps (#8429) by @renovate[bot]
- deps: update npm packages (#8426) by @renovate[bot]
- deps: update rust crate owo-colors to v4.3.0 (#8428) by @renovate[bot]
- deps: update github-actions (#8424) by @renovate[bot]
- deps: update rust crates (#8425) by @renovate[bot]
- deps: update oxc resolver to v11.18.0 (#8406) by @renovate[bot]
- deps: update dependency oxlint-tsgolint to v0.14.2 (#8405) by @renovate[bot]
- ban `expect.assertions` in all fixture tests (#8395) by @sapphi-red
- deps: update oxc apps (#8389) by @renovate[bot]
- ban `expect.assertions` in fixture tests (#8387) by @sapphi-red
- enable lint for `_config.ts` files (#8386) by @sapphi-red
- deps: update dependency oxlint-tsgolint to v0.14.1 (#8385) by @renovate[bot]

Co-authored-by: shulaoda <165626830+shulaoda@users.noreply.github.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.

[Feature Request]: support copy loader

3 participants