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);
+ });
});