diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index f9be9d28d01a..80ef1b955203 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -2286,20 +2286,25 @@ pub mod bv2_impl { }; // 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 { - path: path_as_static(&path_primary), - contents: std::borrow::Cow::Borrowed(&b""[..]), - ..Default::default() - }; - let idx = self - .enqueue_parse_task( + let idx = if self.is_server_html_import(loader, target) { + self.enqueue_server_html_import(&file_map_result, &path_primary, target) + .expect("oom") + } else { + let mut tmp_source = bun_ast::Source { + path: path_as_static(&path_primary), + contents: std::borrow::Cow::Borrowed(&b""[..]), + ..Default::default() + }; + self.enqueue_parse_task( &file_map_result, &mut tmp_source, loader, import_record.original_target, ) - .expect("oom"); - // SAFETY: see `value_ptr` note above. + .expect("oom") + }; + // SAFETY: see `value_ptr` note above; the HTML branch only touches the + // browser graph's map. unsafe { *value_ptr = idx }; let record: &mut ImportRecord = &mut self.graph.ast.items_import_records_mut() @@ -2529,8 +2534,20 @@ pub mod bv2_impl { break 'brk path .loader(unsafe { &(*transpiler).options.loaders }) .unwrap_or(Loader::File); - // HTML is only allowed at the entry point. }; + if self.is_server_html_import(loader, target) { + let manifest_source_index = self + .enqueue_server_html_import(&resolve_result, &path, target) + .expect("oom"); + self.path_to_source_index_map(target) + .put(path.text, manifest_source_index) + .expect("oom"); + let record: &mut ImportRecord = &mut self.graph.ast.items_import_records_mut() + [import_record.importer_source_index as usize] + .as_mut_slice()[import_record.import_record_index as usize]; + record.source_index = Index::init(manifest_source_index); + return; + } let mut tmp_source = bun_ast::Source { path: path_as_static(&path.dupe_alloc(self.arena()).expect("oom")), contents: std::borrow::Cow::Borrowed(&b""[..]), @@ -4774,84 +4791,116 @@ pub mod bv2_impl { .expect("oom"); // `GetOrPutResult` has no `key_ptr` — `get_or_put` already // duped the key into the map (see PathToSourceIndexMap.rs). - - // We need to parse this - let source_index = - Index::init(u32::try_from(this.graph.ast.len()).expect("int cast")); - // SAFETY: map slot from `get_or_put` above; map not mutated since. - 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); - this.graph - .input_files - .append(crate::Graph::InputFile { - source: bun_ast::Source { - // Shim to the field-identical `bun_paths::fs::Path<'static>`. - path: path_as_static(&path), - contents: std::borrow::Cow::Borrowed(&b""[..]), - index: bun_ast::Index(source_index.get()), + if resolve.import_record.kind != ImportKind::EntryPointBuild + && this.is_server_html_import( + loader, + resolve.import_record.original_target, + ) + { + let resolve_result = _resolver::Result { + path_pair: _resolver::PathPair { + primary: path, ..Default::default() }, - loader, - side_effects: bun_ast::SideEffects::HasSideEffects, ..Default::default() - }) - .expect("unreachable"); - let task_val = ParseTask { - // SAFETY: `from_mut(this)` is the live bundle (write provenance); - // outlives the task. - ctx: Some(unsafe { - bun_ptr::ParentRef::from_raw_mut( - std::ptr::from_mut::(this) - .cast::>(), + }; + let manifest_source_index = this + .enqueue_server_html_import( + &resolve_result, + &path, + resolve.import_record.original_target, ) - }), - path, - // unknown at this point: - contents_or_fd: parse_task::ContentsOrFd::Fd { - dir: bun_sys::Fd::INVALID, - file: bun_sys::Fd::INVALID, - }, - side_effects: bun_ast::SideEffects::HasSideEffects, - jsx: this - .transpiler_for_target(resolve.import_record.original_target) - .options - .jsx - .clone(), - source_index: bun_ast::Index::init(source_index.get()), - module_type: options::ModuleType::Unknown, - loader: Some(loader), - tree_shaking: this.linker.options.tree_shaking, - known_target: resolve.import_record.original_target, - ..Default::default() - }; - // Arena-owned. - // SAFETY: arena outlives the bundle pass. - let task: &mut ParseTask = this.arena_create(task_val); - task.task.node.next = core::ptr::null_mut(); - task.io_task.node.next = core::ptr::null_mut(); - this.increment_scan_counter(); - - if !this.enqueue_on_load_plugin_if_needed(task) { - if loader.should_copy_for_bundling() { - let additional_files: &mut bun_alloc::AstVec< - crate::AdditionalFile, - > = &mut this.graph.input_files.items_additional_files_mut() - [source_index.get() as usize]; - additional_files.push(crate::AdditionalFile::SourceIndex( - task.source_index.get(), - )); - this.graph.input_files.items_side_effects_mut() - [source_index.get() as usize] = - bun_ast::SideEffects::NoSideEffectsPureData; - this.graph.estimated_file_loader_count += 1; - } + .expect("oom"); + // SAFETY: map slot from `get_or_put` above; only the browser + // graph's map was touched since. + unsafe { *value_ptr = manifest_source_index }; + out_source_index = Some(Index::init(manifest_source_index)); + } else { + // We need to parse this + let source_index = Index::init( + u32::try_from(this.graph.ast.len()).expect("int cast"), + ); + // SAFETY: map slot from `get_or_put` above; map not mutated since. + 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 + + this.graph + .input_files + .append(crate::Graph::InputFile { + source: bun_ast::Source { + // Shim to the field-identical `bun_paths::fs::Path<'static>`. + path: path_as_static(&path), + contents: std::borrow::Cow::Borrowed(&b""[..]), + index: bun_ast::Index(source_index.get()), + ..Default::default() + }, + loader, + side_effects: bun_ast::SideEffects::HasSideEffects, + ..Default::default() + }) + .expect("unreachable"); + let task_val = ParseTask { + // SAFETY: `from_mut(this)` is the live bundle (write provenance); + // outlives the task. + ctx: Some(unsafe { + bun_ptr::ParentRef::from_raw_mut( + std::ptr::from_mut::(this) + .cast::>(), + ) + }), + path, + // unknown at this point: + contents_or_fd: parse_task::ContentsOrFd::Fd { + dir: bun_sys::Fd::INVALID, + file: bun_sys::Fd::INVALID, + }, + side_effects: bun_ast::SideEffects::HasSideEffects, + jsx: this + .transpiler_for_target( + resolve.import_record.original_target, + ) + .options + .jsx + .clone(), + source_index: bun_ast::Index::init(source_index.get()), + module_type: options::ModuleType::Unknown, + loader: Some(loader), + tree_shaking: this.linker.options.tree_shaking, + known_target: resolve.import_record.original_target, + ..Default::default() + }; + // Arena-owned. + // SAFETY: arena outlives the bundle pass. + let task: &mut ParseTask = this.arena_create(task_val); + task.task.node.next = core::ptr::null_mut(); + task.io_task.node.next = core::ptr::null_mut(); + this.increment_scan_counter(); + + if !this.enqueue_on_load_plugin_if_needed(task) { + if loader.should_copy_for_bundling() { + let additional_files: &mut bun_alloc::AstVec< + crate::AdditionalFile, + > = &mut this + .graph + .input_files + .items_additional_files_mut() + [source_index.get() as usize]; + additional_files.push(crate::AdditionalFile::SourceIndex( + task.source_index.get(), + )); + this.graph.input_files.items_side_effects_mut() + [source_index.get() as usize] = + bun_ast::SideEffects::NoSideEffectsPureData; + this.graph.estimated_file_loader_count += 1; + } - this.graph.pool().schedule(task); + this.graph.pool().schedule(task); + } } } else { // SAFETY: map slot from `get_or_put` above; map not mutated since. @@ -6162,79 +6211,19 @@ 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 stands in for the resolver's result from here 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 { + // No tsconfig.json to take these from. + result.jsx = transpiler.options.jsx.clone(); + break result; + } match transpiler.resolver.resolve_with_framework( source_dir, import_record.path.text, @@ -6545,9 +6534,7 @@ pub mod bv2_impl { }; import_record.loader = Some(import_record_loader); - let is_html_entrypoint = import_record_loader == Loader::Html - && target.is_server_side() - && self.dev_server.is_none(); + let is_html_entrypoint = self.is_server_html_import(import_record_loader, target); if let Some(id) = self.path_to_source_index_map(target).get(path.text) { if self.dev_server.is_some() && loader != Loader::Html { @@ -6571,9 +6558,18 @@ pub mod bv2_impl { continue; } - *path = self - .path_with_pretty_initialized(path, target) - .expect("oom"); + if is_in_memory { + // 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`. + 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 @@ -6610,8 +6606,13 @@ pub mod bv2_impl { } if is_html_entrypoint { - self.generate_server_html_module(path, target, import_record, path.text) + let manifest_source_index = self + .generate_server_html_module(path, target) .expect("unreachable"); + self.path_to_source_index_map(target) + .put(path.text, manifest_source_index) + .expect("oom"); + import_record.source_index = Index::init(manifest_source_index); } } @@ -6632,8 +6633,8 @@ pub mod bv2_impl { let mut diff: i32 = 0; // reshaped for borrowck — `graph` and the // path map are both needed across the loop body. We (a) capture a raw self ptr for - // ParseTask.ctx, (b) hoist dev_server check, and (c) scope the map - // borrow to the get_or_put so later `self.graph.*` writes don't overlap. + // ParseTask.ctx and (b) scope the map borrow to the get_or_put so later + // `self.graph.*` writes don't overlap. // SAFETY: write provenance from `ptr::from_mut`; outlives every ParseTask. let self_ptr: Option, bun_ptr::Mut>> = Some(unsafe { @@ -6641,7 +6642,6 @@ pub mod bv2_impl { std::ptr::from_mut::(self).cast::>(), ) }); - let dev_server_is_none = self.dev_server.is_none(); for (key, value) in resolve_queue.iter() { let value: *mut ParseTask = *value; // SAFETY: ParseTask was arena-allocated in `resolve_import_records`; @@ -6653,8 +6653,7 @@ pub mod bv2_impl { .loader(&self.transpiler.options.loaders) .unwrap_or(Loader::File) }); - let is_html_entrypoint = - loader == Loader::Html && target.is_server_side() && dev_server_is_none; + let is_html_entrypoint = self.is_server_html_import(loader, target); // Select map and perform get_or_put, capturing the slot as a raw ptr // so the &mut on self.graph is released before we touch other fields. let (found_existing, value_ptr): (bool, *mut IndexInt) = { @@ -6840,16 +6839,14 @@ pub mod bv2_impl { } } + /// The module a server-side import of `path` binds to: a placeholder export the + /// linker replaces with the page's manifest. The caller registers the returned + /// index in `target`'s path map. fn generate_server_html_module( &mut self, path: &Fs::Path, target: options::Target, - import_record: &mut ImportRecord, - path_text: &[u8], - ) -> Result<(), Error> { - // 1. Create the ast right here - // 2. Create a separate "virutal" module that becomes the manifest later on. - // 3. Add it to the graph + ) -> Result { // Re-borrow `self.graph` // at each use so the `self.*` method calls below don't conflict. let heap = self.graph.heap; @@ -6924,16 +6921,55 @@ pub mod bv2_impl { self.graph.input_files.append(fake_input_file)?; let _ = self.graph.ast.append(ast_for_html_entrypoint); // OOM/capacity: fire-and-forget - import_record.source_index = Index::init(fake_source_index.0); - let _ = self - .path_to_source_index_map(target) - .put(path_text, fake_source_index.0); // OOM-only Result self.graph .html_imports .server_source_indices .push(fake_source_index.0); self.ensure_client_transpiler(); - Ok(()) + Ok(fake_source_index.0) + } + + /// Whether an import resolved with `loader` from a `target` graph binds to a + /// manifest module. The dev server serves HTML files as routes instead. + fn is_server_html_import(&self, loader: Loader, target: options::Target) -> bool { + loader == Loader::Html && target.is_server_side() && self.dev_server.is_none() + } + + /// HTML import resolved outside the bulk pass: creates the manifest module (the + /// caller registers and binds the returned index) and parses the page as a + /// browser entry point unless that graph already has it. + fn enqueue_server_html_import( + &mut self, + resolve_result: &_resolver::Result, + path: &Fs::Path, + target: options::Target, + ) -> Result { + let manifest_source_index = self.generate_server_html_module(path, target)?; + + if self + .path_to_source_index_map(Target::Browser) + .get(path.text) + .is_none() + { + let mut html_source = bun_ast::Source { + path: path_as_static(path), + contents: std::borrow::Cow::Borrowed(&b""[..]), + ..Default::default() + }; + let html_source_index = self.enqueue_parse_task( + resolve_result, + &mut html_source, + Loader::Html, + Target::Browser, + )?; + self.path_to_source_index_map(Target::Browser) + .put(path.text, html_source_index)?; + self.graph + .entry_points + .push(bun_ast::Index(html_source_index)); + } + + Ok(manifest_source_index) } } diff --git a/test/bundler/bundler_files.test.ts b/test/bundler/bundler_files.test.ts index 81a5d904576b..cf3129621fb9 100644 --- a/test/bundler/bundler_files.test.ts +++ b/test/bundler/bundler_files.test.ts @@ -1,3 +1,4 @@ +import type { BunPlugin } from "bun"; import { describe, expect, test } from "bun:test"; import { tempDir } from "harness"; @@ -582,4 +583,223 @@ describe("bundler files option", () => { const output = await result.outputs[0].text(); expect(output).toContain("injected by plugin"); }); + + test("in-memory imports are parsed with 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")`); + // In-memory files are labelled by their key, not by a path relative to cwd. + expect(output).toContain("// /app/child.jsx"); + }); + + describe("HTML imports", () => { + type Manifest = { + index: string; + files: Array<{ input: string; path: string; loader: string; isEntry: boolean; headers: Record }>; + }; + + // Importing an .html file into a server build replaces the import with a + // `__jsonParse("")` module describing the browser build of that page. + function manifestsIn(serverCode: string): Manifest[] { + return [...serverCode.matchAll(/__jsonParse\("(.+?)"\)/gs)].map(m => JSON.parse(JSON.parse(`"${m[1]}"`))); + } + + const basename = (path: string) => path.split(/[\\/]/).pop()!; + + function outputText(result: Awaited>, path: string) { + const output = result.outputs.find(o => basename(o.path) === basename(path)); + if (!output) throw new Error(`no output named ${basename(path)} in ${result.outputs.map(o => o.path)}`); + return output.text(); + } + + const pageHtml = ``; + const pageCss = `body { color: red }`; + const clientJs = `document.title = "client";`; + + test("server build importing an in-memory .html file gets a manifest and a browser bundle", async () => { + const result = await Bun.build({ + entrypoints: ["/app/server.js"], + target: "bun", + files: { + "/app/server.js": `import page from "./page.html"; export default page;`, + "/app/page.html": pageHtml, + "/app/page.css": pageCss, + "/app/client.js": clientJs, + }, + }); + + const server = await outputText(result, "server.js"); + expect(server).toStartWith("// @bun\n"); + expect(server).not.toMatch(/from "[^"]*page\.html"/); + + const [manifest, ...extra] = manifestsIn(server); + expect(extra).toBeEmpty(); + expect(manifest.index).toMatch(/page\.html$/); + expect(manifest.files.toSorted((a, b) => a.loader.localeCompare(b.loader))).toEqual([ + { + input: expect.stringContaining("page.html"), + path: expect.stringMatching(/\.css$/), + loader: "css", + isEntry: true, + headers: { "etag": expect.any(String), "content-type": "text/css;charset=utf-8" }, + }, + { + input: expect.stringContaining("page.html"), + path: manifest.index, + loader: "html", + isEntry: true, + headers: { "etag": expect.any(String), "content-type": "text/html;charset=utf-8" }, + }, + { + input: expect.stringContaining("page.html"), + path: expect.stringMatching(/\.js$/), + loader: "js", + isEntry: true, + headers: { "etag": expect.any(String), "content-type": "text/javascript;charset=utf-8" }, + }, + ]); + + const { path: cssPath } = manifest.files.find(f => f.loader === "css")!; + const { path: jsPath } = manifest.files.find(f => f.loader === "js")!; + const html = await outputText(result, manifest.index); + expect(html).toContain(basename(cssPath)); + expect(html).toContain(basename(jsPath)); + + // The page's script is bundled for the browser even though the build targets bun. + const client = await outputText(result, jsPath); + expect(client).toContain(clientJs); + expect(client).not.toContain("// @bun"); + expect(await outputText(result, cssPath)).toContain("color: red"); + }); + + test("file on disk importing an in-memory .html file", async () => { + using dir = tempDir("bundler-files-html-import", { + "server.js": `import page from "./page.html"; export default page;`, + }); + + const result = await Bun.build({ + entrypoints: [`${dir}/server.js`], + target: "bun", + files: { + [`${dir}/page.html`]: pageHtml, + [`${dir}/page.css`]: pageCss, + [`${dir}/client.js`]: clientJs, + }, + }); + + const [manifest, ...extra] = manifestsIn(await outputText(result, "server.js")); + expect(extra).toBeEmpty(); + expect(manifest.index).toMatch(/page\.html$/); + expect(manifest.files.map(f => f.loader).toSorted()).toEqual(["css", "html", "js"]); + const { path: jsPath } = manifest.files.find(f => f.loader === "js")!; + expect(await outputText(result, manifest.index)).toContain(basename(jsPath)); + expect(await outputText(result, jsPath)).toContain(clientJs); + }); + + test("each in-memory .html file gets one manifest, however often it is imported", async () => { + const result = await Bun.build({ + entrypoints: ["/app/server.js"], + target: "bun", + files: { + "/app/server.js": ` + import home from "./home.html"; + import about from "./about.html"; + import { home as homeAgain } from "./routes.js"; + export default { home, about, homeAgain }; + `, + "/app/routes.js": `export { default as home } from "./home.html";`, + "/app/home.html": ``, + "/app/about.html": ``, + "/app/home.js": `console.log("home");`, + "/app/about.js": `console.log("about");`, + }, + }); + + const server = await outputText(result, "server.js"); + expect(server).not.toMatch(/from "[^"]*\.html"/); + + const manifests = manifestsIn(server).toSorted((a, b) => a.index.localeCompare(b.index)); + expect(manifests.map(m => basename(m.index))).toEqual(["about.html", "home.html"]); + for (const manifest of manifests) { + expect(manifest.files.map(f => f.loader).toSorted()).toEqual(["html", "js"]); + const { path: jsPath } = manifest.files.find(f => f.loader === "js")!; + const pageName = basename(manifest.index).replace(".html", ""); + expect(await outputText(result, jsPath)).toContain(`console.log("${pageName}")`); + } + }); + + // With an onResolve plugin registered, imports matching its filter are resolved one at a + // time (by the plugin, or by the regular resolver when the plugin declines) instead of by + // the bulk pass the tests above go through. The in-memory page has to become a manifest + // on those paths as well. + async function expectManifestWithPlugin(specifier: string, plugin: BunPlugin) { + const result = await Bun.build({ + entrypoints: ["/app/server.js"], + target: "bun", + files: { + "/app/server.js": `import page from "${specifier}"; export default page;`, + "/app/page.html": pageHtml, + "/app/page.css": pageCss, + "/app/client.js": clientJs, + }, + plugins: [plugin], + }); + + const server = await outputText(result, "server.js"); + expect(server).not.toContain(`from "${specifier}"`); + const [manifest, ...extra] = manifestsIn(server); + expect(extra).toBeEmpty(); + expect(manifest.index).toMatch(/page\.html$/); + expect(manifest.files.map(f => f.loader).toSorted()).toEqual(["css", "html", "js"]); + expect(result.outputs.filter(o => o.path.endsWith(".html"))).toHaveLength(1); + + const { path: jsPath } = manifest.files.find(f => f.loader === "js")!; + const client = await outputText(result, jsPath); + expect(client).toContain(clientJs); + expect(client).not.toContain("// @bun"); + } + + test("imported through an onResolve plugin that declines the import", async () => { + await expectManifestWithPlugin("./page.html", { + name: "decline-relative-imports", + setup(build) { + build.onResolve({ filter: /^\.\// }, () => undefined); + }, + }); + }); + + test("imported through an onResolve plugin that returns the in-memory file's path", async () => { + await expectManifestWithPlugin("app:page", { + name: "resolve-to-in-memory-page", + setup(build) { + build.onResolve({ filter: /^app:page$/ }, () => ({ path: "/app/page.html" })); + }, + }); + }); + + test("assets referenced by an in-memory .html file are copied to the output", async () => { + const result = await Bun.build({ + entrypoints: ["/app/index.html"], + files: { + "/app/index.html": ``, + "/app/manifest.json": `{"name":"app"}`, + }, + }); + + const html = await outputText(result, "index.html"); + expect(html).toMatch(/href="[^"]*manifest-[a-zA-Z0-9]+\.json"/); + + const asset = result.outputs.find(o => o.kind === "asset"); + expect(asset?.path).toMatch(/manifest-[a-zA-Z0-9]+\.json$/); + expect(await asset!.text()).toBe(`{"name":"app"}`); + }); + }); }); diff --git a/test/bundler/html-import-manifest.test.ts b/test/bundler/html-import-manifest.test.ts index e18ac20bd8a8..37128f271c3f 100644 --- a/test/bundler/html-import-manifest.test.ts +++ b/test/bundler/html-import-manifest.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { tempDir } from "harness"; import { readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { SourceMapConsumer } from "source-map"; import { itBundled } from "./expectBundled"; @@ -524,4 +524,177 @@ console.log("✓ Both import types work correctly"); expect(entryCode).toContain('\\\"files\\\":['); }, }); + + // With an onResolve plugin registered, import records that match its filter + // are resolved one at a time by the plugin callback (and by the regular + // resolver when the callback declines) instead of by the bulk resolution + // pass. A server-side import of an HTML file has to produce the same + // manifest module and browser entry point on those paths too. + const pluginResolvedHtmlFiles = { + "/page.html": ` + + + + + + + +

Page

+ +`, + "/page.css": `body { margin: 0; }`, + "/page.js": `console.log("page script");`, + }; + + const printManifest = /* js */ ` +console.log(JSON.stringify({ index: manifest.index.split("/").pop(), loaders: manifest.files.map(f => f.loader).sort() })); +`; + const expectedManifestSummary = `{"index":"page.html","loaders":["css","html","js"]}`; + + function readManifests(api: { readFile(file: string): string }, file: string) { + return [...api.readFile(file).matchAll(/__jsonParse\("(.+?)"\)/gs)].map(match => + JSON.parse(JSON.parse('"' + match[1] + '"')), + ); + } + + function expectSingleHtmlOutput(build: { outputs: { path: string }[] }) { + const htmlOutputs = build.outputs.map(output => basename(output.path)).filter(file => file.endsWith(".html")); + expect(htmlOutputs).toEqual(["page.html"]); + } + + itBundled("html-import/onresolve-fallthrough", { + outdir: "out/", + metafile: true, + files: { + ...pluginResolvedHtmlFiles, + "/server.js": ` +import manifest from "./page.html"; +import { manifest as importedByOtherModule } from "./other.js"; +console.log(manifest === importedByOtherModule); +${printManifest}`, + "/other.js": `export { default as manifest } from "./page.html";`, + }, + entryPoints: ["/server.js"], + target: "bun", + plugins(builder) { + builder.onResolve({ filter: /.*/ }, () => undefined); + }, + run: { + stdout: `true\n${expectedManifestSummary}`, + }, + onAfterApiBundle: expectSingleHtmlOutput, + onAfterBundle(api) { + const manifests = readManifests(api, "out/server.js"); + expect(manifests).toHaveLength(1); + + // The HTML file's scripts were bundled for the browser, not for the + // server target of the graph that imported it. + const script = manifests[0].files.find((file: any) => file.loader === "js"); + const scriptCode = api.readFile("out/" + script.path); + expect(scriptCode).toContain("page script"); + expect(scriptCode).not.toContain("// @bun"); + + // The import record keeps its own kind; only its target changes. + const metafile = JSON.parse(api.readFile("metafile.json")); + expect(metafile.inputs["server.js"].imports).toContainEqual( + expect.objectContaining({ path: "page.html", kind: "import-statement" }), + ); + }, + }); + + // Unlike an import statement, a require() of the HTML file used to build + // without an error on this path and evaluate to an empty module. + itBundled("html-import/onresolve-fallthrough-require", { + outdir: "out/", + files: { + ...pluginResolvedHtmlFiles, + "/server.js": ` +const required = require("./page.html"); +const manifest = required.default ?? required; +${printManifest}`, + }, + entryPoints: ["/server.js"], + target: "bun", + plugins(builder) { + builder.onResolve({ filter: /.*/ }, () => undefined); + }, + run: { + stdout: expectedManifestSummary, + }, + onAfterApiBundle: expectSingleHtmlOutput, + onAfterBundle(api) { + expect(readManifests(api, "out/server.js")).toHaveLength(1); + }, + }); + + itBundled("html-import/onresolve-returns-html-path", ({ root }) => ({ + outdir: "out/", + files: { + ...pluginResolvedHtmlFiles, + "/server.js": ` +import manifest from "app:page"; +${printManifest}`, + }, + entryPoints: ["/server.js"], + target: "bun", + plugins(builder) { + builder.onResolve({ filter: /^app:page$/ }, () => ({ path: join(root, "page.html") })); + }, + run: { + stdout: expectedManifestSummary, + }, + onAfterApiBundle: expectSingleHtmlOutput, + onAfterBundle(api) { + expect(readManifests(api, "out/server.js")).toHaveLength(1); + }, + })); + + // page.html is an entry point of its own (resolved without the plugin, whose + // filter only matches relative specifiers) and imported by server.js + // (resolved through the plugin). It must be bundled once and the import must + // still get a manifest for it. + itBundled("html-import/onresolve-fallthrough-html-is-also-an-entry-point", { + outdir: "out/", + files: { + ...pluginResolvedHtmlFiles, + "/server.js": ` +import manifest from "./page.html"; +${printManifest}`, + }, + entryPoints: ["/server.js", "/page.html"], + target: "bun", + plugins(builder) { + builder.onResolve({ filter: /^\.\// }, () => undefined); + }, + run: { + file: "out/server.js", + stdout: expectedManifestSummary, + }, + onAfterApiBundle: expectSingleHtmlOutput, + onAfterBundle(api) { + expect(readManifests(api, "out/server.js")).toHaveLength(1); + }, + }); + + itBundled("html-import/onresolve-fallthrough-keeps-type-file-attribute", { + outdir: "out/", + files: { + ...pluginResolvedHtmlFiles, + "/server.js": ` +import url from "./page.html" with { type: "file" }; +console.log(typeof url, url.endsWith(".html")); +`, + }, + entryPoints: ["/server.js"], + target: "bun", + plugins(builder) { + builder.onResolve({ filter: /.*/ }, () => undefined); + }, + run: { + stdout: "string true", + }, + onAfterBundle(api) { + expect(readManifests(api, "out/server.js")).toHaveLength(0); + }, + }); });