diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index f9be9d28d01a..34274a37bb92 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -2270,7 +2270,7 @@ pub mod bv2_impl { ) }; if !found_existing { - let loader: Loader = 'brk: { + let resolved_loader: Loader = 'brk: { let record: &mut ImportRecord = &mut self.graph.ast.items_import_records_mut() [import_record.importer_source_index as usize] @@ -2284,6 +2284,7 @@ pub mod bv2_impl { .loader(unsafe { &(*transpiler).options.loaders }) .unwrap_or(Loader::File); }; + let loader = self.loader_for_plugin_import(import_record, resolved_loader); // For virtual files, use the path text as-is (no relative path computation needed). path_primary.pretty = self.arena().alloc_slice_copy(path_primary.text); let mut tmp_source = bun_ast::Source { @@ -2518,7 +2519,7 @@ pub mod bv2_impl { if let Some(p) = resolve_result.path() { *p = path; } - let loader: Loader = 'brk: { + let resolved_loader: Loader = 'brk: { let record: &ImportRecord = &self.graph.ast.items_import_records() [import_record.importer_source_index as usize] .as_slice()[import_record.import_record_index as usize]; @@ -2529,8 +2530,8 @@ pub mod bv2_impl { break 'brk path .loader(unsafe { &(*transpiler).options.loaders }) .unwrap_or(Loader::File); - // HTML is only allowed at the entry point. }; + let loader = self.loader_for_plugin_import(import_record, resolved_loader); let mut tmp_source = bun_ast::Source { path: path_as_static(&path.dupe_alloc(self.arena()).expect("oom")), contents: std::borrow::Cow::Borrowed(&b""[..]), @@ -4782,9 +4783,11 @@ pub mod bv2_impl { unsafe { *value_ptr = source_index.get() }; out_source_index = Some(source_index); let _ = this.graph.ast.append(JSAst::empty_in(this.graph.heap)); // OOM/capacity: fire-and-forget - let loader = path - .loader(&this.transpiler.options.loaders) - .unwrap_or(Loader::File); + let loader = this.loader_for_plugin_import( + &resolve.import_record, + path.loader(&this.transpiler.options.loaders) + .unwrap_or(Loader::File), + ); this.graph .input_files @@ -5942,7 +5945,53 @@ pub mod bv2_impl { pub(crate) last_error: Option, } + /// The loader for a file imported from a file bundled with `importer_loader`, + /// given the loader it gets on its own (`resolved_loader`: the import + /// attribute, or the one registered for its extension). Every way of + /// resolving an import record (the resolver, `Bun.build({ files })`, an + /// onResolve plugin) goes through here so they all bundle the file the same way. + /// + /// A url reference in an HTML document (``, ``) names a file the browser fetches + /// as-is, so it is copied to the output like a `file` loader asset even when + /// its extension has a loader that would parse it (json, toml, text, ...). + /// Scripts, stylesheets and documents referenced from HTML are bundled + /// instead, and `url()` references from CSS keep their loaders. + fn loader_for_import( + importer_loader: Loader, + kind: ImportKind, + resolved_loader: Loader, + ) -> Loader { + if importer_loader == Loader::Html + && kind == ImportKind::Url + && !resolved_loader.should_copy_for_bundling() + && !resolved_loader.is_javascript_like() + && !resolved_loader.is_css() + && resolved_loader != Loader::Html + { + return Loader::File; + } + resolved_loader + } + impl<'a> BundleV2<'a> { + /// [`loader_for_import`] for a record that went through onResolve plugins; + /// its importer is read back from the graph. An entry point has no importer. + fn loader_for_plugin_import( + &self, + import_record: &jsc_api::JSBundler::MiniImportRecord, + resolved_loader: Loader, + ) -> Loader { + if import_record.kind == ImportKind::EntryPointBuild { + return resolved_loader; + } + loader_for_import( + self.graph.input_files.items_loader()[import_record.importer_source_index as usize], + import_record.kind, + resolved_loader, + ) + } + /// Resolve all unresolved import records for a module. Skips records that /// are already resolved (valid source_index), unused, or internal. /// Returns a resolve queue of new modules to schedule, plus any fatal error. @@ -6162,79 +6211,21 @@ pub mod bv2_impl { // SAFETY: see note above — raw `*mut Transpiler` lives for `'a`. let transpiler: &mut Transpiler<'a> = unsafe { &mut *transpiler_ptr }; - // Check the FileMap first for in-memory files - if let Some(file_map) = self.file_map { - if let Some(_file_map_result) = + // An in-memory file (`Bun.build({ files })`) takes the place of the + // resolver's result and is handled like a file on disk from there on. + let in_memory_result: Option<_resolver::Result> = + self.file_map.and_then(|file_map| { file_map.resolve(self.arena(), source.path.text, import_record.path.text) - { - let mut file_map_result = _file_map_result; - let mut path_primary = file_map_result.path_pair.primary; - let import_record_loader = import_record.loader.unwrap_or_else(|| { - Fs::Path::init(path_primary.text) - .loader(&transpiler.options.loaders) - .unwrap_or(Loader::File) - }); - import_record.loader = Some(import_record_loader); - - if let Some(id) = - self.path_to_source_index_map(target).get(path_primary.text) - { - import_record.source_index = Index::init(id); - continue; - } - - let resolve_entry = - resolve_queue.get_or_put(path_primary.text).expect("oom"); - if resolve_entry.found_existing { - // SAFETY: arena-allocated `ParseTask` stored in the queue; arena outlives the pass. - import_record.path = - path_as_static(&unsafe { &**resolve_entry.value_ptr }.path); - continue; - } - - // For virtual files, use the path text as-is (no relative path computation needed). - // SAFETY: arena outlives the bundle pass; raw-pointer detour erases the - // `&self` lifetime so the resulting `&'static [u8]` doesn't pin `self` - // (otherwise `path_primary: Path<'static>` forces `&self: 'static`, - // cascading borrow conflicts into every `&mut self` call below). - path_primary.pretty = unsafe { - bun_ptr::detach_lifetime( - self.arena().alloc_slice_copy(path_primary.text), - ) - }; - import_record.path = path_as_static(&path_primary); - let _ = path_primary.text; // key already interned by get_or_put - bun_core::scoped_log!( - Bundle, - "created ParseTask from FileMap: {}", - bstr::BStr::new(&path_primary.text) - ); - file_map_result.path_pair.primary = path_primary; - // Arena-owned. - let resolve_task_val = - ParseTask::init(&file_map_result, bun_ast::Index::INVALID, self); - // SAFETY: arena outlives the bundle pass. - let resolve_task: &mut ParseTask = self.arena_create(resolve_task_val); - resolve_task.known_target = target; - // Use transpiler JSX options, applying force_node_env like the disk path does - resolve_task.jsx = transpiler.options.jsx.clone(); - resolve_task.jsx.development = match transpiler.options.force_node_env { - options::ForceNodeEnv::Development => true, - options::ForceNodeEnv::Production => false, - options::ForceNodeEnv::Unspecified => { - transpiler.options.jsx.development - } - }; - resolve_task.loader = Some(import_record_loader); - resolve_task.tree_shaking = transpiler.options.tree_shaking; - resolve_task.side_effects = bun_ast::SideEffects::HasSideEffects; - *resolve_entry.value_ptr = resolve_task; - continue; - } - } - + }); + let is_in_memory = in_memory_result.is_some(); let mut had_busted_dir_cache = false; let resolve_result: _resolver::Result = 'inner: loop { + if let Some(mut result) = in_memory_result { + // The resolver fills this in from the transpiler options and the + // file's tsconfig.json; an in-memory file has no tsconfig.json. + result.jsx = transpiler.options.jsx.clone(); + break result; + } match transpiler.resolver.resolve_with_framework( source_dir, import_record.path.text, @@ -6522,27 +6513,14 @@ pub mod bv2_impl { } } - let import_record_loader = 'brk: { - let resolved_loader = import_record.loader.unwrap_or_else(|| { + let import_record_loader = loader_for_import( + loader, + import_record.kind, + import_record.loader.unwrap_or_else(|| { path.loader(&transpiler.options.loaders) .unwrap_or(Loader::File) - }); - // When an HTML file references a URL asset (e.g. ), - // the file must be copied to the output directory as-is. If the resolved loader would - // parse/transform the file (e.g. .json, .toml) rather than copy it, force the .file loader - // so that `shouldCopyForBundling()` returns true and the asset is emitted. - // Only do this for HTML sources — CSS url() imports should retain their original behavior. - if loader == Loader::Html - && import_record.kind == ImportKind::Url - && !resolved_loader.should_copy_for_bundling() - && !resolved_loader.is_javascript_like() - && !resolved_loader.is_css() - && resolved_loader != Loader::Html - { - break 'brk Loader::File; - } - break 'brk resolved_loader; - }; + }), + ); import_record.loader = Some(import_record_loader); let is_html_entrypoint = import_record_loader == Loader::Html @@ -6571,9 +6549,19 @@ pub mod bv2_impl { continue; } - *path = self - .path_with_pretty_initialized(path, target) - .expect("oom"); + if is_in_memory { + // An in-memory file is displayed by its key in `files`, not by a path + // relative to the project. + // SAFETY: arena outlives the bundle pass; raw-pointer detour erases the + // `&self` lifetime so the resulting `&'static [u8]` doesn't pin `self`. + path.pretty = unsafe { + bun_ptr::detach_lifetime(self.arena().alloc_slice_copy(path.text)) + }; + } else { + *path = self + .path_with_pretty_initialized(path, target) + .expect("oom"); + } import_record.path = path_as_static(path); // key already interned by get_or_put — no key_ptr on StringHashMapGetOrPut diff --git a/test/bundler/bundler_files.test.ts b/test/bundler/bundler_files.test.ts index 81a5d904576b..38430cf9db98 100644 --- a/test/bundler/bundler_files.test.ts +++ b/test/bundler/bundler_files.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { tempDir } from "harness"; +import { basename } from "node:path"; describe("bundler files option", () => { test("basic in-memory file bundling", async () => { @@ -582,4 +583,180 @@ describe("bundler files option", () => { const output = await result.outputs[0].text(); expect(output).toContain("injected by plugin"); }); + + test("in-memory imports use the build's jsx options", async () => { + const result = await Bun.build({ + entrypoints: ["/app/entry.js"], + files: { + "/app/entry.js": `import "./child.jsx";`, + "/app/child.jsx": `console.log(
child
);`, + }, + jsx: { runtime: "classic", factory: "myFactory", fragment: "MyFragment" }, + }); + + const output = await result.outputs[0].text(); + expect(output).toContain(`myFactory("div", null, "child")`); + }); + + test("import attributes pick the loader of in-memory imports", async () => { + const result = await Bun.build({ + entrypoints: ["/app/entry.js"], + files: { + "/app/entry.js": `import data from "./data.json" with { type: "text" }; console.log(data);`, + "/app/data.json": `{"answer": 42}`, + }, + }); + + const output = await result.outputs[0].text(); + expect(output).toContain(`'{"answer": 42}'`); + }); + + test("a server-side build importing an in-memory HTML file gets its manifest", async () => { + const result = await Bun.build({ + entrypoints: ["/app/server.js"], + target: "bun", + files: { + "/app/server.js": `import page from "./page.html"; console.log(page.index);`, + "/app/page.html": ``, + "/app/client.js": `console.log("client script");`, + }, + throw: false, + }); + expect(result.logs).toEqual([]); + expect(result.success).toBe(true); + + const server = await result.outputs.find(output => basename(output.path) === "server.js")!.text(); + // The import is replaced by the manifest of the page's browser build, which + // is emitted next to the server code. + expect(server).not.toMatch(/^import /m); + const manifests = [...server.matchAll(/__jsonParse\("(.+?)"\)/gs)].map(match => + JSON.parse(JSON.parse(`"${match[1]}"`)), + ); + expect(manifests).toEqual([ + { + index: expect.stringMatching(/page\.html$/), + files: expect.arrayContaining([ + expect.objectContaining({ loader: "html" }), + expect.objectContaining({ loader: "js" }), + ]), + }, + ]); + const scriptPath = manifests[0].files.find((file: { loader: string }) => file.loader === "js").path; + const script = await result.outputs.find(output => basename(output.path) === basename(scriptPath))!.text(); + expect(script).toContain("client script"); + // The page's script is bundled for the browser even though the build targets bun. + expect(script).not.toContain("// @bun"); + }); + + // A url reference in an HTML document (, , ...) is + // copied to the output as an asset even when its extension has a loader that would + // parse it, exactly as when the files are on disk (see html/manifest-json in + // bundler_html.test.ts). Each case resolves the reference on a different path: the + // bulk resolution pass, the resolver behind a declining onResolve callback, and a + // path returned by onResolve. + describe("url assets referenced from HTML", () => { + const manifestJson = `{"name":"app"}`; + const pageHtml = ``; + const pageJs = `console.log("page script");`; + + async function expectManifestCopied(result: Awaited>) { + expect(result.logs).toEqual([]); + expect(result.success).toBe(true); + + const assets = result.outputs.filter(output => output.kind === "asset"); + expect(assets.map(asset => basename(asset.path))).toEqual([ + expect.stringMatching(/^manifest-[a-zA-Z0-9]+\.json$/), + ]); + expect(await assets[0].text()).toBe(manifestJson); + + const html = await result.outputs.find(output => output.path.endsWith(".html"))!.text(); + expect(html).toContain(`${basename(assets[0].path)}"`); + expect(html).not.toContain(`manifest.json"`); + + // The page's script is still bundled, not copied. + const script = await result.outputs.find(output => output.path.endsWith(".js"))!.text(); + expect(script).toContain("page script"); + } + + test("from an in-memory HTML file", async () => { + await expectManifestCopied( + await Bun.build({ + entrypoints: ["/app/page.html"], + files: { + "/app/page.html": pageHtml, + "/app/manifest.json": manifestJson, + "/app/page.js": pageJs, + }, + throw: false, + }), + ); + }); + + test("from an HTML file on disk", async () => { + using dir = tempDir("bundler-files-html-asset", { "page.html": pageHtml }); + + await expectManifestCopied( + await Bun.build({ + entrypoints: [`${dir}/page.html`], + files: { + [`${dir}/manifest.json`]: manifestJson, + [`${dir}/page.js`]: pageJs, + }, + throw: false, + }), + ); + }); + + test("when an onResolve callback declines the reference", async () => { + const declined: string[] = []; + + await expectManifestCopied( + await Bun.build({ + entrypoints: ["/app/page.html"], + files: { + "/app/page.html": pageHtml, + "/app/manifest.json": manifestJson, + "/app/page.js": pageJs, + }, + plugins: [ + { + name: "decline-manifest", + setup(build) { + build.onResolve({ filter: /manifest\.json$/ }, args => { + declined.push(args.path); + return undefined; + }); + }, + }, + ], + throw: false, + }), + ); + + expect(declined).toEqual(["./manifest.json"]); + }); + + test("when an onResolve callback returns the path of an in-memory file", async () => { + await expectManifestCopied( + await Bun.build({ + entrypoints: ["/app/page.html"], + files: { + "/app/page.html": pageHtml, + // Only reachable through the plugin: the HTML references ./manifest.json. + "/app/generated/manifest.json": manifestJson, + "/app/page.js": pageJs, + }, + plugins: [ + { + name: "redirect-manifest", + setup(build) { + build.onResolve({ filter: /manifest\.json$/ }, () => ({ path: "/app/generated/manifest.json" })); + }, + }, + ], + throw: false, + }), + ); + }); + }); }); diff --git a/test/bundler/bundler_html.test.ts b/test/bundler/bundler_html.test.ts index 12e5f19398a3..323f8281c277 100644 --- a/test/bundler/bundler_html.test.ts +++ b/test/bundler/bundler_html.test.ts @@ -1,5 +1,6 @@ import { describe, expect } from "bun:test"; -import { itBundled } from "./expectBundled"; +import path from "node:path"; +import { type BundlerTestBundleAPI, itBundled } from "./expectBundled"; describe("bundler", () => { // Basic test for bundling HTML with JS and CSS @@ -983,6 +984,61 @@ body { }, }); + // The same rule applies when the reference is resolved through an onResolve + // plugin instead of by the bulk resolution pass: the resolver runs after the + // callback declines, or the callback returns the path itself. + const manifestViaPluginFiles = { + "/index.html": ``, + "/app.js": "console.log('hello')", + }; + function expectManifestCopied(api: BundlerTestBundleAPI) { + const htmlContent = api.readFile("out/index.html"); + expect(htmlContent).not.toContain('manifest.json"'); + const manifestMatch = htmlContent.match(/href="(?:\.\/|\/)?(manifest-[a-zA-Z0-9]+\.json)"/); + expect(manifestMatch).not.toBeNull(); + expect(api.readFile("out/" + manifestMatch![1])).toBe('{"name":"My App"}'); + // The page's script is still bundled, not copied. + expect(htmlContent).toMatch(/src="(?:\.\/|\/)?[^"]+\.js"/); + } + + itBundled("html/manifest-json-onresolve-declines", () => { + const declined: string[] = []; + return { + outdir: "out/", + files: { + ...manifestViaPluginFiles, + "/manifest.json": '{"name":"My App"}', + }, + entryPoints: ["/index.html"], + plugins(builder) { + builder.onResolve({ filter: /manifest\.json$/ }, args => { + declined.push(args.path); + return undefined; + }); + }, + onAfterBundle(api) { + expect(declined).toEqual(["./manifest.json"]); + expectManifestCopied(api); + }, + }; + }); + + itBundled("html/manifest-json-onresolve-path", ({ root }) => ({ + outdir: "out/", + files: { + ...manifestViaPluginFiles, + // Only reachable through the plugin: index.html references ./manifest.json. + "/generated/manifest.json": '{"name":"My App"}', + }, + entryPoints: ["/index.html"], + plugins(builder) { + builder.onResolve({ filter: /manifest\.json$/ }, () => ({ + path: path.join(root, "generated", "manifest.json"), + })); + }, + onAfterBundle: expectManifestCopied, + })); + // Test that other non-JS/CSS file types referenced via URL imports are copied as assets itBundled("html/xml-asset", { outdir: "out/",