diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index f9be9d28d01a..13a89add653f 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -6559,10 +6559,6 @@ pub mod bv2_impl { continue; } - if is_html_entrypoint { - import_record.kind = ImportKind::HtmlManifest; - } - let resolve_entry = resolve_queue.get_or_put(path.text).expect("oom"); if resolve_entry.found_existing { // SAFETY: arena-allocated `ParseTask` stored in the queue; arena outlives the pass. @@ -6584,7 +6580,7 @@ pub mod bv2_impl { // SAFETY: arena outlives the bundle pass. let resolve_task: &mut ParseTask = self.arena_create(resolve_task_val); - resolve_task.known_target = if import_record.kind == ImportKind::HtmlManifest { + resolve_task.known_target = if is_html_entrypoint { Target::Browser } else { target @@ -6892,7 +6888,7 @@ pub mod bv2_impl { (&raw mut *transpiler.options.define, transpiler.log) }; - let ast_for_html_entrypoint = JSAst::init( + let mut ast_for_html_entrypoint = JSAst::init( bun_js_parser::new_lazy_export_ast( heap, // SAFETY: `define`/`log` live for `'a` (owned by the Transpiler). @@ -6913,6 +6909,8 @@ pub mod bv2_impl { )? .unwrap(), ); + // The parser defaults `target` to browser; this module belongs to the importing side. + ast_for_html_entrypoint.target = target; let fake_input_file = crate::Graph::InputFile { source: empty_html_file_source.clone(), diff --git a/test/bundler/html-import-manifest.test.ts b/test/bundler/html-import-manifest.test.ts index e18ac20bd8a8..763939b69cbb 100644 --- a/test/bundler/html-import-manifest.test.ts +++ b/test/bundler/html-import-manifest.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { tempDir } from "harness"; +import { bunRun, tempDir } from "harness"; import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { SourceMapConsumer } from "source-map"; @@ -524,4 +524,132 @@ console.log("✓ Both import types work correctly"); expect(entryCode).toContain('\\\"files\\\":['); }, }); + + // Server code importing an HTML file binds the import to a generated manifest + // module. import() and require() of that module go through the same linker + // paths as import()/require() of any other module (wrapping it, making it a + // chunk of its own when splitting), which only works if the import record + // keeps its real kind. + const pageFiles = { + "page.html": `hi`, + "app.ts": `console.log("app");`, + }; + + async function buildServer(dir: string, entrypoints: string[], options: Partial = {}) { + const result = await Bun.build({ + entrypoints: entrypoints.map(entry => join(dir, entry)), + outdir: join(dir, "out"), + target: "bun", + ...options, + }); + expect(result.logs).toBeEmpty(); + return result; + } + + // `json` must be the manifest object itself (not a module namespace around it) + // for page.html, whose browser build is exactly the HTML file and its JS entry chunk. + function expectPageManifest(json: string) { + const manifest: { index: string; files: Array<{ path: string; loader: string; isEntry: boolean }> } = + JSON.parse(json); + expect(Object.keys(manifest)).toEqual(["index", "files"]); + expect(manifest.index).toBe("./page.html"); + expect(manifest.files.map(f => [f.loader, f.isEntry]).sort()).toEqual([ + ["html", true], + ["js", true], + ]); + return manifest; + } + + test("html-import/dynamic-import", async () => { + await using dir = tempDir("html-import-dynamic", { + ...pageFiles, + "server.ts": ` + const promise = import("./page.html"); + console.log(promise instanceof Promise); + const { default: manifest } = await promise; + console.log(JSON.stringify(manifest)); + `, + }); + + const result = await buildServer(dir, ["server.ts"], { metafile: true }); + + const { stdout, stderr, exitCode } = await bunRun(join(dir, "out", "server.js")); + expect(stderr).toBe(""); + const [isPromise, manifestJson] = stdout.split("\n"); + expect(isPromise).toBe("true"); + expectPageManifest(manifestJson); + expect(exitCode).toBe(0); + + const [, serverInput] = Object.entries(result.metafile!.inputs).find(([path]) => path.endsWith("server.ts"))!; + expect(serverInput.imports.map(({ kind, original }) => ({ kind, original }))).toEqual([ + { kind: "dynamic-import", original: "./page.html" }, + ]); + }); + + test("html-import/dynamic-import-with-splitting", async () => { + await using dir = tempDir("html-import-dynamic-splitting", { + ...pageFiles, + "server.ts": ` + const { default: manifest } = await import("./page.html"); + console.log(JSON.stringify(manifest)); + `, + }); + + await buildServer(dir, ["server.ts"], { splitting: true }); + + // With splitting, the manifest module is loaded lazily from a chunk of its own. + const serverCode = readFileSync(join(dir, "out", "server.js"), "utf8"); + const lazyChunk = serverCode.match(/import\("(\.\/[^"]+)"\)/)![1]; + expect(readFileSync(join(dir, "out", lazyChunk), "utf8")).toStartWith("// @bun\n"); + + const { stdout, stderr, exitCode } = await bunRun(join(dir, "out", "server.js")); + expect(stderr).toBe(""); + const manifest = expectPageManifest(stdout); + // That chunk is server code, so the manifest must not list it as a browser asset. + expect(manifest.files.map(f => f.path)).not.toContain(lazyChunk); + expect(exitCode).toBe(0); + }); + + // Like require() of a JSON file, require() of an HTML file evaluates to the + // manifest itself, whether the requiring file is ESM or CommonJS. + test.each(["esm", "cjs"])("html-import/require-from-%s", async kind => { + await using dir = tempDir(`html-import-require-${kind}`, { + ...pageFiles, + "server.ts": ` + ${kind === "esm" ? "export {};" : ""} + const manifest = require("./page.html"); + console.log(JSON.stringify(manifest)); + `, + }); + + await buildServer(dir, ["server.ts"]); + + const { stdout, stderr, exitCode } = await bunRun(join(dir, "out", "server.js")); + expect(stderr).toBe(""); + expectPageManifest(stdout); + expect(exitCode).toBe(0); + }); + + // With splitting, a manifest module imported by several server entry points + // lands in a shared chunk. That chunk is server code: it must not be listed in + // the manifest as one of the page's browser files. + test("html-import/splitting-shared-manifest-chunk", async () => { + await using dir = tempDir("html-import-splitting-shared", { + ...pageFiles, + "a.ts": `import manifest from "./page.html"; console.log(JSON.stringify(manifest));`, + "b.ts": `import manifest from "./page.html"; console.log(manifest.files.length);`, + }); + + await buildServer(dir, ["a.ts", "b.ts"], { splitting: true }); + + const aCode = readFileSync(join(dir, "out", "a.js"), "utf8"); + const sharedChunk = aCode.match(/from "(\.\/[^"]+)"/)![1]; + expect(readFileSync(join(dir, "out", "b.js"), "utf8")).toContain(`from "${sharedChunk}"`); + + const { stdout, stderr, exitCode } = await bunRun(join(dir, "out", "a.js")); + expect(stderr).toBe(""); + const manifest = expectPageManifest(stdout); + expect(manifest.files.map(f => f.path)).not.toContain(sharedChunk); + expect(exitCode).toBe(0); + }); });