Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions src/bundler/bundle_v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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).
Expand All @@ -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(),
Expand Down
130 changes: 129 additions & 1 deletion test/bundler/html-import-manifest.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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": `<!doctype html><html><head><script type="module" src="./app.ts"></script></head><body>hi</body></html>`,
"app.ts": `console.log("app");`,
};

async function buildServer(dir: string, entrypoints: string[], options: Partial<Bun.BuildConfig> = {}) {
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);
});
});
Loading