Conversation
How to use the Graphite Merge QueueAdd 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. |
✅ Deploy Preview for rolldown-rs canceled.
|
There was a problem hiding this comment.
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_moduleand 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. |
Benchmarks Rust |
244ebd6 to
82c7e6c
Compare
Merge activity
|
## 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
82c7e6c to
803e775
Compare
## 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
803e775 to
e3b4aea
Compare
| 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; | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| // 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)), |
There was a problem hiding this comment.
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.
| // 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, |
## [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>

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:How it works
The implementation lives in a new builtin plugin (
rolldown_plugin_copy_module) that operates in two phases:Phase 1:
resolve_idWhen rolldown encounters an import like
import img from './photo.png':ctx.emit_file_async()— theFileEmitterhashes the content with XXH3-128 and generates a filename from the configuredassetFileNamestemplate (default:assets/[name]-[hash][extname])__ROLLDOWN_COPY_MODULE__#<reference_id>) marked asexternal: trueMarking 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_chunkAfter chunk code is generated (containing
import img from "__ROLLDOWN_COPY_MODULE__#ref123"):memchr::memmemfor SIMD-accelerated search of all placeholder positionsctx.get_file_name()Result:
import img from "./assets/photo-4dj8Fk2L.png"Changes
crates/rolldown_plugin_copy_module/— new builtin plugin cratecrates/rolldown_common/.../module_type.rs— addsModuleType::Copyvariantcrates/rolldown/src/utils/apply_inner_plugins.rs— registers the plugin when copy extensions are configuredcrates/rolldown/src/utils/load_source.rs— handlesCopymodule type in the load phasecrates/rolldown/src/utils/parse_to_ecma_ast.rs— treatsCopymodules as empty JS (they're externalized by the plugin)packages/rolldown/src/options/input-options.ts— adds'copy'to theModuleTypeunion🤖 Generated with Claude Code
Closes #6411