diff --git a/src/bundler/linker_context/postProcessJSChunk.zig b/src/bundler/linker_context/postProcessJSChunk.zig index ccbc8754d5..110b3870cd 100644 --- a/src/bundler/linker_context/postProcessJSChunk.zig +++ b/src/bundler/linker_context/postProcessJSChunk.zig @@ -117,49 +117,67 @@ pub fn postProcessJSChunk(ctx: GenerateChunkCtx, worker: *ThreadPool.Worker, chu var newline_before_comment = false; var is_executable = false; - // Start with the hashbang if there is one. This must be done before the - // banner because it only works if it's literally the first character. - if (chunk.isEntryPoint()) { - const is_bun = c.graph.ast.items(.target)[chunk.entry_point.source_index].isBun(); - const hashbang = c.graph.ast.items(.hashbang)[chunk.entry_point.source_index]; + // Extract hashbang and banner for entry points + const hashbang, const banner = if (chunk.isEntryPoint()) brk: { + const source_hashbang = c.graph.ast.items(.hashbang)[chunk.entry_point.source_index]; - if (hashbang.len > 0) { - j.pushStatic(hashbang); - j.pushStatic("\n"); - line_offset.advance(hashbang); - line_offset.advance("\n"); - newline_before_comment = true; - is_executable = true; + // If source file has a hashbang, use it + if (source_hashbang.len > 0) { + break :brk .{ source_hashbang, c.options.banner }; } - if (is_bun) { - const cjs_entry_chunk = "(function(exports, require, module, __filename, __dirname) {"; - if (ctx.c.options.generate_bytecode_cache and output_format == .cjs) { - const input = "// @bun @bytecode @bun-cjs\n" ++ cjs_entry_chunk; - j.pushStatic(input); - line_offset.advance(input); - } else if (ctx.c.options.generate_bytecode_cache) { - j.pushStatic("// @bun @bytecode\n"); - line_offset.advance("// @bun @bytecode\n"); - } else if (output_format == .cjs) { - j.pushStatic("// @bun @bun-cjs\n" ++ cjs_entry_chunk); - line_offset.advance("// @bun @bun-cjs\n" ++ cjs_entry_chunk); - } else { - j.pushStatic("// @bun\n"); - line_offset.advance("// @bun\n"); - } + // Otherwise check if banner starts with hashbang + if (c.options.banner.len > 0 and strings.hasPrefixComptime(c.options.banner, "#!")) { + const newline_pos = strings.indexOfChar(c.options.banner, '\n') orelse c.options.banner.len; + const banner_hashbang = c.options.banner[0..newline_pos]; + + break :brk .{ banner_hashbang, std.mem.trimLeft(u8, c.options.banner[newline_pos..], "\r\n") }; + } + + // No hashbang anywhere + break :brk .{ "", c.options.banner }; + } else .{ "", c.options.banner }; + + // Start with the hashbang if there is one. This must be done before the + // banner because it only works if it's literally the first character. + if (hashbang.len > 0) { + j.pushStatic(hashbang); + j.pushStatic("\n"); + line_offset.advance(hashbang); + line_offset.advance("\n"); + newline_before_comment = true; + is_executable = true; + } + + // Add @bun comments and CJS wrapper start for each chunk when targeting Bun. + const is_bun = c.graph.ast.items(.target)[chunk.entry_point.source_index].isBun(); + if (is_bun) { + const cjs_entry_chunk = "(function(exports, require, module, __filename, __dirname) {"; + if (ctx.c.options.generate_bytecode_cache and output_format == .cjs) { + const input = "// @bun @bytecode @bun-cjs\n" ++ cjs_entry_chunk; + j.pushStatic(input); + line_offset.advance(input); + } else if (ctx.c.options.generate_bytecode_cache) { + j.pushStatic("// @bun @bytecode\n"); + line_offset.advance("// @bun @bytecode\n"); + } else if (output_format == .cjs) { + j.pushStatic("// @bun @bun-cjs\n" ++ cjs_entry_chunk); + line_offset.advance("// @bun @bun-cjs\n" ++ cjs_entry_chunk); + } else { + j.pushStatic("// @bun\n"); + line_offset.advance("// @bun\n"); } } - if (c.options.banner.len > 0) { - if (newline_before_comment) { + // Add the banner (excluding any hashbang part) for all chunks + if (banner.len > 0) { + j.pushStatic(banner); + line_offset.advance(banner); + if (!strings.endsWithChar(banner, '\n')) { j.pushStatic("\n"); line_offset.advance("\n"); } - j.pushStatic(ctx.c.options.banner); - line_offset.advance(ctx.c.options.banner); - j.pushStatic("\n"); - line_offset.advance("\n"); + newline_before_comment = true; } // Add the top-level directive if present (but omit "use strict" in ES @@ -372,12 +390,9 @@ pub fn postProcessJSChunk(ctx: GenerateChunkCtx, worker: *ThreadPool.Worker, chu } }, .cjs => { - if (chunk.isEntryPoint()) { - const is_bun = ctx.c.graph.ast.items(.target)[chunk.entry_point.source_index].isBun(); - if (is_bun) { - j.pushStatic("})\n"); - line_offset.advance("})\n"); - } + if (is_bun) { + j.pushStatic("})\n"); + line_offset.advance("})\n"); } }, else => {}, diff --git a/test/bundler/bundler_banner.test.ts b/test/bundler/bundler_banner.test.ts index 8a783b66fc..19bed4f424 100644 --- a/test/bundler/bundler_banner.test.ts +++ b/test/bundler/bundler_banner.test.ts @@ -1,4 +1,4 @@ -import { describe } from "bun:test"; +import { describe, expect } from "bun:test"; import { itBundled } from "./expectBundled"; describe("bundler", () => { @@ -33,4 +33,174 @@ describe("bundler", () => { api.expectFile("out.js").toContain('"use client";'); }, }); + + itBundled("banner/BannerWithCJSAndTargetBun", { + banner: "// Copyright 2024 Example Corp", + format: "cjs", + target: "bun", + backend: "api", + outdir: "/out", + minifyWhitespace: true, + files: { + "a.js": `module.exports = 1;`, + }, + onAfterBundle(api) { + const content = api.readFile("/out/a.js"); + expect(content).toMatchInlineSnapshot(` + "// @bun @bun-cjs + (function(exports, require, module, __filename, __dirname) {// Copyright 2024 Example Corp + module.exports=1;}) + " + `); + }, + }); + + itBundled("banner/HashbangBannerWithCJSAndTargetBun", { + banner: "#!/usr/bin/env -S node --enable-source-maps\n// Additional banner content", + format: "cjs", + target: "bun", + backend: "api", + outdir: "/out", + minifyWhitespace: true, + files: { + "/a.js": `module.exports = 1;`, + }, + onAfterBundle(api) { + const content = api.readFile("/out/a.js"); + expect(content).toMatchInlineSnapshot(` + "#!/usr/bin/env -S node --enable-source-maps + // @bun @bun-cjs + (function(exports, require, module, __filename, __dirname) {// Additional banner content + module.exports=1;}) + " + `); + }, + }); + + itBundled("banner/SourceHashbangWithBannerAndCJSTargetBun", { + banner: "// Copyright 2024 Example Corp", + format: "cjs", + target: "bun", + outdir: "/out", + minifyWhitespace: true, + backend: "api", + files: { + "/a.js": `#!/usr/bin/env node +module.exports = 1;`, + }, + onAfterBundle(api) { + const content = api.readFile("/out/a.js"); + expect(content).toMatchInlineSnapshot(` + "#!/usr/bin/env node + // @bun @bun-cjs + (function(exports, require, module, __filename, __dirname) {// Copyright 2024 Example Corp + module.exports=1;}) + " + `); + }, + }); + + itBundled("banner/BannerWithESMAndTargetBun", { + banner: "// Copyright 2024 Example Corp", + format: "esm", + target: "bun", + backend: "api", + minifyWhitespace: true, + files: { + "/a.js": `export default 1;`, + }, + onAfterBundle(api) { + const content = api.readFile("out.js"); + // @bun comment should come first, then banner + const bunCommentIndex = content.indexOf("// @bun"); + const bannerIndex = content.indexOf("// Copyright 2024 Example Corp"); + + expect(bunCommentIndex).toBe(0); + expect(bannerIndex).toBeGreaterThan(bunCommentIndex); + // No CJS wrapper in ESM format + expect(content).not.toContain("(function(exports, require, module, __filename, __dirname)"); + expect(content).toMatchInlineSnapshot(` + "// @bun + // Copyright 2024 Example Corp + var a_default=1;export{a_default as default}; + " + `); + }, + }); + + itBundled("banner/HashbangBannerWithESMAndTargetBun", { + banner: "#!/usr/bin/env -S node --enable-source-maps\n// Additional banner content", + format: "esm", + target: "bun", + backend: "api", + outdir: "/out", + minifyWhitespace: true, + files: { + "/a.js": `export default 1;`, + }, + onAfterBundle(api) { + const content = api.readFile("/out/a.js"); + expect(content).toMatchInlineSnapshot(` + "#!/usr/bin/env -S node --enable-source-maps + // @bun + // Additional banner content + var a_default=1;export{a_default as default}; + " + `); + }, + }); + + itBundled("banner/BannerWithBytecodeAndCJSTargetBun", { + banner: "// Copyright 2024 Example Corp", + format: "cjs", + target: "bun", + backend: "api", + bytecode: true, + minifyWhitespace: true, + outdir: "/out", + files: { + "/a.js": `module.exports = 1;`, + }, + onAfterBundle(api) { + const content = api.readFile("/out/a.js"); + expect(content).toMatchInlineSnapshot(` + "// @bun @bytecode @bun-cjs + (function(exports, require, module, __filename, __dirname) {// Copyright 2024 Example Corp + module.exports=1;}) + " + `); + // @bun @bytecode @bun-cjs comment should come first, then CJS wrapper, then banner + const bunBytecodeIndex = content.indexOf("// @bun @bytecode @bun-cjs"); + const wrapperIndex = content.indexOf("(function(exports, require, module, __filename, __dirname) {"); + const bannerIndex = content.indexOf("// Copyright 2024 Example Corp"); + + expect(bunBytecodeIndex).toBe(0); + expect(wrapperIndex).toBeGreaterThan(bunBytecodeIndex); + expect(bannerIndex).toBeGreaterThan(wrapperIndex); + }, + }); + + itBundled("banner/HashbangBannerWithBytecodeAndCJSTargetBun", { + banner: "#!/usr/bin/env bun\n// Production build", + format: "cjs", + target: "bun", + bytecode: true, + backend: "api", + outdir: "/out", + minifyWhitespace: true, + files: { + "/a.js": `module.exports = 1;`, + }, + onAfterBundle(api) { + const content = api.readFile("/out/a.js"); + + expect(content).toMatchInlineSnapshot(` + "#!/usr/bin/env bun + // @bun @bytecode @bun-cjs + (function(exports, require, module, __filename, __dirname) {// Production build + module.exports=1;}) + " + `); + }, + }); }); diff --git a/test/bundler/bundler_naming.test.ts b/test/bundler/bundler_naming.test.ts index 8f4aeb5281..e2870b399b 100644 --- a/test/bundler/bundler_naming.test.ts +++ b/test/bundler/bundler_naming.test.ts @@ -237,6 +237,7 @@ describe("bundler", () => { }, }); itBundled("naming/NonexistantRoot", ({ root }) => ({ + backend: "cli", files: { "/src/entry.js": /* js */ ` import asset1 from "./asset1.file"; diff --git a/test/bundler/bundler_plugin.test.ts b/test/bundler/bundler_plugin.test.ts index 7d5b2dee15..c81aa651d1 100644 --- a/test/bundler/bundler_plugin.test.ts +++ b/test/bundler/bundler_plugin.test.ts @@ -820,12 +820,13 @@ describe("bundler", () => { }, external: ["esbuild"], entryPoints: ["./index.ts"], + backend: "api", plugins(build) { const opts = (build as any).initialOptions; expect(opts.bundle).toEqual(true); expect(opts.entryPoints).toEqual([join(root, "index.ts")]); expect(opts.external).toEqual(["esbuild"]); - expect(opts.format).toEqual(undefined); + expect(opts.format).toEqual("esm"); expect(opts.minify).toEqual(false); expect(opts.minifyIdentifiers).toEqual(undefined); expect(opts.minifySyntax).toEqual(undefined); diff --git a/test/bundler/expectBundled.ts b/test/bundler/expectBundled.ts index 289c8585cd..ee0ff16fc7 100644 --- a/test/bundler/expectBundled.ts +++ b/test/bundler/expectBundled.ts @@ -1043,7 +1043,12 @@ function expectBundled( const buildConfig: BuildConfig = { entrypoints: [...entryPaths, ...(entryPointsRaw ?? [])], external, + banner, + format, + footer, + root: outbase, packages, + loader, minify: { whitespace: minifyWhitespace, identifiers: minifyIdentifiers, diff --git a/test/bundler/html-import-manifest.test.ts b/test/bundler/html-import-manifest.test.ts index 99fe0eef2d..55f1153a2c 100644 --- a/test/bundler/html-import-manifest.test.ts +++ b/test/bundler/html-import-manifest.test.ts @@ -152,6 +152,7 @@ console.log(favicon); // Test manifest with multiple HTML imports itBundled("html-import/multiple-manifests", { outdir: "out/", + backend: "cli", files: { "/server.js": ` import homeHtml from "./home.html"; @@ -301,6 +302,7 @@ console.log("About manifest:", aboutHtml); // Test that import with {type: 'file'} still works as a file import itBundled("html-import/with-type-file-attribute", { outdir: "out/", + backend: "cli", files: { "/entry.js": ` import htmlUrl from "./page.html" with { type: 'file' };