diff --git a/src/StandaloneModuleGraph.zig b/src/StandaloneModuleGraph.zig index 267d1f83f1..eee598ea68 100644 --- a/src/StandaloneModuleGraph.zig +++ b/src/StandaloneModuleGraph.zig @@ -97,6 +97,12 @@ pub const StandaloneModuleGraph = struct { encoding: Encoding = .latin1, loader: bun.options.Loader = .file, module_format: ModuleFormat = .none, + side: FileSide = .server, + }; + + pub const FileSide = enum(u8) { + server = 0, + client = 1, }; pub const Encoding = enum(u8) { @@ -141,6 +147,11 @@ pub const StandaloneModuleGraph = struct { wtf_string: bun.String = bun.String.empty, bytecode: []u8 = "", module_format: ModuleFormat = .none, + side: FileSide = .server, + + pub fn appearsInEmbeddedFilesArray(this: *const File) bool { + return this.side == .client or !this.loader.isJavaScriptLike(); + } pub fn stat(this: *const File) bun.Stat { var result = std.mem.zeroes(bun.Stat); @@ -300,6 +311,7 @@ pub const StandaloneModuleGraph = struct { .none, .bytecode = if (module.bytecode.length > 0) @constCast(sliceTo(raw_bytes, module.bytecode)) else &.{}, .module_format = module.module_format, + .side = module.side, }, ); } @@ -347,8 +359,10 @@ pub const StandaloneModuleGraph = struct { string_builder.cap += (output_file.value.buffer.bytes.len + 255) / 256 * 256 + 256; } else { if (entry_point_id == null) { - if (output_file.output_kind == .@"entry-point") { - entry_point_id = module_count; + if (output_file.side == null or output_file.side.? == .server) { + if (output_file.output_kind == .@"entry-point") { + entry_point_id = module_count; + } } } @@ -421,6 +435,10 @@ pub const StandaloneModuleGraph = struct { else => .none, } else .none, .bytecode = bytecode, + .side = switch (output_file.side orelse .server) { + .server => .server, + .client => .client, + }, }; if (output_file.source_map_index != std.math.maxInt(u32)) { @@ -839,7 +857,7 @@ pub const StandaloneModuleGraph = struct { .fromStdDir(root_dir), bun.sliceTo(&(try std.posix.toPosixPath(std.fs.path.basename(outfile))), 0), ) catch |err| { - if (err == error.IsDir) { + if (err == error.IsDir or err == error.EISDIR) { Output.prettyErrorln("error: {} is a directory. Please choose a different --outfile or delete the directory", .{bun.fmt.quote(outfile)}); } else { Output.prettyErrorln("error: failed to rename {s} to {s}: {s}", .{ temp_location, outfile, @errorName(err) }); diff --git a/src/bun.js/api/BunObject.zig b/src/bun.js/api/BunObject.zig index a84db98bc3..7263c2ccec 100644 --- a/src/bun.js/api/BunObject.zig +++ b/src/bun.js/api/BunObject.zig @@ -1315,7 +1315,7 @@ pub fn getEmbeddedFiles(globalThis: *JSC.JSGlobalObject, _: *JSC.JSObject) bun.J // We don't really do that right now, but exposing the output source // code here as an easily accessible Blob is even worse for them. // So let's omit any source code files from the list. - if (unsorted_files[index].loader.isJavaScriptLike()) continue; + if (!unsorted_files[index].appearsInEmbeddedFilesArray()) continue; sort_indices.appendAssumeCapacity(@intCast(index)); } diff --git a/src/bun.js/api/server.zig b/src/bun.js/api/server.zig index 50b3bbbaf0..1d755a01b0 100644 --- a/src/bun.js/api/server.zig +++ b/src/bun.js/api/server.zig @@ -118,7 +118,121 @@ pub const AnyRoute = union(enum) { } } - pub fn htmlRouteFromJS(argument: JSC.JSValue, init_ctx: *ServerInitContext) ?AnyRoute { + fn bundledHTMLManifestItemFromJS(argument: JSC.JSValue, index_path: []const u8, init_ctx: *ServerInitContext) bun.JSError!?AnyRoute { + if (!argument.isObject()) return null; + + const path_string = try bun.String.fromJS(try argument.get(init_ctx.global, "path") orelse return null, init_ctx.global); + defer path_string.deref(); + var path = JSC.Node.PathOrFileDescriptor{ .path = try JSC.Node.PathLike.fromBunString(init_ctx.global, path_string, false, bun.default_allocator) }; + defer path.deinit(); + + // Construct the route by stripping paths above the root. + // + // "./index-abc.js" -> "/index-abc.js" + // "../index-abc.js" -> "/index-abc.js" + // "/index-abc.js" -> "/index-abc.js" + // "index-abc.js" -> "/index-abc.js" + // + const cwd = if (bun.StandaloneModuleGraph.isBunStandaloneFilePath(path.path.slice())) + bun.StandaloneModuleGraph.targetBasePublicPath(bun.Environment.os, "root/") + else + bun.fs.FileSystem.instance.top_level_dir; + + const abs_path = bun.fs.FileSystem.instance.abs(&[_][]const u8{path.path.slice()}); + var relative_path = bun.fs.FileSystem.instance.relative(cwd, abs_path); + + if (strings.hasPrefixComptime(relative_path, "./")) { + relative_path = relative_path[2..]; + } else if (strings.hasPrefixComptime(relative_path, "../")) { + while (strings.hasPrefixComptime(relative_path, "../")) { + relative_path = relative_path[3..]; + } + } + const is_index_route = bun.strings.eql(path.path.slice(), index_path); + var builder = std.ArrayList(u8).init(bun.default_allocator); + defer builder.deinit(); + if (!strings.hasPrefixComptime(relative_path, "/")) { + try builder.append('/'); + } + + try builder.appendSlice(relative_path); + + const fetch_headers = JSC.WebCore.FetchHeaders.createFromJS(init_ctx.global, try argument.get(init_ctx.global, "headers") orelse return null); + defer if (fetch_headers) |headers| headers.deref(); + if (init_ctx.global.hasException()) return error.JSError; + + const route = try fromOptions(init_ctx.global, fetch_headers, &path); + + if (is_index_route) { + return route; + } + + var methods = HTTP.Method.Optional{ .method = .initEmpty() }; + methods.insert(.GET); + methods.insert(.HEAD); + + try init_ctx.user_routes.append(.{ + .path = try builder.toOwnedSlice(), + .route = route, + .method = methods, + }); + return null; + } + + /// This is the JS representation of an HTMLImportManifest + /// + /// See ./src/bundler/HTMLImportManifest.zig + fn bundledHTMLManifestFromJS(argument: JSC.JSValue, init_ctx: *ServerInitContext) bun.JSError!?AnyRoute { + if (!argument.isObject()) return null; + + const index = try argument.getOptional(init_ctx.global, "index", ZigString.Slice) orelse return null; + defer index.deinit(); + + const files = try argument.getArray(init_ctx.global, "files") orelse return null; + var iter = try files.arrayIterator(init_ctx.global); + var html_route: ?AnyRoute = null; + while (try iter.next()) |file_entry| { + if (try bundledHTMLManifestItemFromJS(file_entry, index.slice(), init_ctx)) |item| { + html_route = item; + } + } + + return html_route; + } + + pub fn fromOptions(global: *JSC.JSGlobalObject, headers: ?*JSC.WebCore.FetchHeaders, path: *JSC.Node.PathOrFileDescriptor) !AnyRoute { + // The file/static route doesn't ref it. + var blob = Blob.findOrCreateFileFromPath(path, global, false); + + if (blob.needsToReadFile()) { + // Throw a more helpful error upfront if the file does not exist. + // + // In production, you do NOT want to find out that all the assets + // are 404'ing when the user goes to the route. You want to find + // that out immediately so that the health check on startup fails + // and the process exits with a non-zero status code. + if (blob.store) |store| { + if (store.getPath()) |store_path| { + switch (bun.sys.existsAtType(bun.FD.cwd(), store_path)) { + .result => |file_type| { + if (file_type == .directory) { + return global.throwInvalidArguments("Bundled file {} cannot be a directory. You may want to configure --asset-naming or `naming` when bundling.", .{bun.fmt.quote(store_path)}); + } + }, + .err => { + return global.throwInvalidArguments("Bundled file {} not found. You may want to configure --asset-naming or `naming` when bundling.", .{bun.fmt.quote(store_path)}); + }, + } + } + } + + return AnyRoute{ .file = FileRoute.initFromBlob(blob, .{ .server = null, .headers = headers }) }; + } + + return AnyRoute{ .static = StaticRoute.initFromAnyBlob(&.{ .Blob = blob }, .{ .server = null, .headers = headers }) }; + } + + pub fn htmlRouteFromJS(argument: JSC.JSValue, init_ctx: *ServerInitContext) bun.JSError!?AnyRoute { if (argument.as(HTMLBundle)) |html_bundle| { const entry = init_ctx.dedupe_html_bundle_map.getOrPut(html_bundle) catch bun.outOfMemory(); if (!entry.found_existing) { @@ -129,6 +243,10 @@ pub const AnyRoute = union(enum) { } } + if (try bundledHTMLManifestFromJS(argument, init_ctx)) |html_route| { + return html_route; + } + return null; } @@ -136,7 +254,9 @@ pub const AnyRoute = union(enum) { arena: std.heap.ArenaAllocator, dedupe_html_bundle_map: std.AutoHashMap(*HTMLBundle, bun.ptr.RefPtr(HTMLBundle.Route)), js_string_allocations: bun.bake.StringRefList, + global: *JSC.JSGlobalObject, framework_router_list: std.ArrayList(bun.bake.Framework.FileSystemRouterType), + user_routes: *std.ArrayList(ServerConfig.StaticRouteEntry), }; pub fn fromJS( @@ -145,7 +265,7 @@ pub const AnyRoute = union(enum) { argument: JSC.JSValue, init_ctx: *ServerInitContext, ) bun.JSError!?AnyRoute { - if (AnyRoute.htmlRouteFromJS(argument, init_ctx)) |html_route| { + if (try AnyRoute.htmlRouteFromJS(argument, init_ctx)) |html_route| { return html_route; } diff --git a/src/bun.js/api/server/FileRoute.zig b/src/bun.js/api/server/FileRoute.zig index 2dbdca9a88..d5c1e55369 100644 --- a/src/bun.js/api/server/FileRoute.zig +++ b/src/bun.js/api/server/FileRoute.zig @@ -12,6 +12,7 @@ has_content_length_header: bool, pub const InitOptions = struct { server: ?AnyServer, status_code: u16 = 200, + headers: ?*JSC.WebCore.FetchHeaders = null, }; pub fn lastModifiedDate(this: *const FileRoute) ?u64 { @@ -34,12 +35,14 @@ pub fn lastModifiedDate(this: *const FileRoute) ?u64 { } pub fn initFromBlob(blob: Blob, opts: InitOptions) *FileRoute { - const headers = Headers.from(null, bun.default_allocator, .{ .body = &.{ .Blob = blob } }) catch bun.outOfMemory(); + const headers = Headers.from(opts.headers, bun.default_allocator, .{ .body = &.{ .Blob = blob } }) catch bun.outOfMemory(); return bun.new(FileRoute, .{ .ref_count = .init(), .server = opts.server, .blob = blob, .headers = headers, + .has_last_modified_header = headers.get("last-modified") != null, + .has_content_length_header = headers.get("content-length") != null, .status_code = opts.status_code, }); } diff --git a/src/bun.js/api/server/ServerConfig.zig b/src/bun.js/api/server/ServerConfig.zig index 964b08b3c9..a46bead0b1 100644 --- a/src/bun.js/api/server/ServerConfig.zig +++ b/src/bun.js/api/server/ServerConfig.zig @@ -506,12 +506,15 @@ pub fn fromJS( }).init(global, static_obj); defer iter.deinit(); - var init_ctx: AnyRoute.ServerInitContext = .{ + var init_ctx_: AnyRoute.ServerInitContext = .{ .arena = .init(bun.default_allocator), .dedupe_html_bundle_map = .init(bun.default_allocator), .framework_router_list = .init(bun.default_allocator), .js_string_allocations = .empty, + .user_routes = &args.static_routes, + .global = global, }; + const init_ctx: *AnyRoute.ServerInitContext = &init_ctx_; errdefer { init_ctx.arena.deinit(); init_ctx.framework_router_list.deinit(); @@ -593,7 +596,7 @@ pub fn fromJS( }, .callback = .create(function.withAsyncContextIfNeeded(global), global), }) catch bun.outOfMemory(); - } else if (try AnyRoute.fromJS(global, path, function, &init_ctx)) |html_route| { + } else if (try AnyRoute.fromJS(global, path, function, init_ctx)) |html_route| { var method_set = bun.http.Method.Set.initEmpty(); method_set.insert(method); @@ -612,7 +615,7 @@ pub fn fromJS( } } - const route = try AnyRoute.fromJS(global, path, value, &init_ctx) orelse { + const route = try AnyRoute.fromJS(global, path, value, init_ctx) orelse { return global.throwInvalidArguments( \\'routes' expects a Record Response|Promise}> \\ diff --git a/src/bun.js/api/server/StaticRoute.zig b/src/bun.js/api/server/StaticRoute.zig index f1874d916f..616bb2bfec 100644 --- a/src/bun.js/api/server/StaticRoute.zig +++ b/src/bun.js/api/server/StaticRoute.zig @@ -22,11 +22,12 @@ pub const InitFromBytesOptions = struct { server: ?AnyServer, mime_type: ?*const bun.http.MimeType = null, status_code: u16 = 200, + headers: ?*JSC.WebCore.FetchHeaders = null, }; /// Ownership of `blob` is transferred to this function. pub fn initFromAnyBlob(blob: *const AnyBlob, options: InitFromBytesOptions) *StaticRoute { - var headers = Headers.from(null, bun.default_allocator, .{ .body = blob }) catch bun.outOfMemory(); + var headers = Headers.from(options.headers, bun.default_allocator, .{ .body = blob }) catch bun.outOfMemory(); if (options.mime_type) |mime_type| { if (headers.getContentType() == null) { headers.append("Content-Type", mime_type.value) catch bun.outOfMemory(); diff --git a/src/bun.js/webcore/Blob.zig b/src/bun.js/webcore/Blob.zig index 22995f2f3e..aa9e30105a 100644 --- a/src/bun.js/webcore/Blob.zig +++ b/src/bun.js/webcore/Blob.zig @@ -1899,6 +1899,7 @@ pub fn findOrCreateFileFromPath(path_or_fd: *JSC.Node.PathOrFileDescriptor, glob } } } + const path: JSC.Node.PathOrFileDescriptor = brk: { switch (path_or_fd.*) { .path => { diff --git a/src/bundler/Chunk.zig b/src/bundler/Chunk.zig index f9aace07f7..fa58ce93b5 100644 --- a/src/bundler/Chunk.zig +++ b/src/bundler/Chunk.zig @@ -27,6 +27,7 @@ pub const Chunk = struct { is_executable: bool = false, has_html_chunk: bool = false, + is_browser_chunk_from_server_build: bool = false, output_source_map: sourcemap.SourceMapPieces, diff --git a/src/bundler/HTMLImportManifest.zig b/src/bundler/HTMLImportManifest.zig index 58a199cdea..a187d1f5d9 100644 --- a/src/bundler/HTMLImportManifest.zig +++ b/src/bundler/HTMLImportManifest.zig @@ -128,13 +128,26 @@ pub fn write(index: u32, graph: *const Graph, linker_graph: *const LinkerGraph, try writer.writeAll("{"); + const inject_compiler_filesystem_prefix = bv2.transpiler.options.compile; + // Use the server-side public path here. + const public_path = bv2.transpiler.options.public_path; + var temp_buffer = std.ArrayList(u8).init(bun.default_allocator); + defer temp_buffer.deinit(); + for (chunks) |*ch| { if (ch.entry_point.source_index == browser_source_index and ch.entry_point.is_entry_point) { entry_point_bits.set(ch.entry_point.entry_point_id); if (ch.content == .html) { try writer.writeAll("\"index\":"); - try bun.js_printer.writeJSONString(ch.final_rel_path, @TypeOf(writer), writer, .utf8); + if (inject_compiler_filesystem_prefix) { + temp_buffer.clearRetainingCapacity(); + try temp_buffer.appendSlice(public_path); + try temp_buffer.appendSlice(bun.strings.removeLeadingDotSlash(ch.final_rel_path)); + try bun.js_printer.writeJSONString(temp_buffer.items, @TypeOf(writer), writer, .utf8); + } else { + try bun.js_printer.writeJSONString(ch.final_rel_path, @TypeOf(writer), writer, .utf8); + } try writer.writeAll(","); } } @@ -167,13 +180,20 @@ pub fn write(index: u32, graph: *const Graph, linker_graph: *const LinkerGraph, .posix, false, ); - if (path_for_key.len > 2 and strings.eqlComptime(path_for_key[0..2], "./")) { - path_for_key = path_for_key[2..]; - } + + path_for_key = bun.strings.removeLeadingDotSlash(path_for_key); break :brk path_for_key; }, - ch.final_rel_path, + brk: { + if (inject_compiler_filesystem_prefix) { + temp_buffer.clearRetainingCapacity(); + try temp_buffer.appendSlice(public_path); + try temp_buffer.appendSlice(bun.strings.removeLeadingDotSlash(ch.final_rel_path)); + break :brk temp_buffer.items; + } + break :brk ch.final_rel_path; + }, ch.isolated_hash, ch.content.loader(), if (ch.entry_point.is_entry_point) @@ -203,14 +223,20 @@ pub fn write(index: u32, graph: *const Graph, linker_graph: *const LinkerGraph, .posix, false, ); - if (path_for_key.len > 2 and strings.eqlComptime(path_for_key[0..2], "./")) { - path_for_key = path_for_key[2..]; - } + path_for_key = bun.strings.removeLeadingDotSlash(path_for_key); try writeEntryItem( writer, path_for_key, - output_file.dest_path, + brk: { + if (inject_compiler_filesystem_prefix) { + temp_buffer.clearRetainingCapacity(); + try temp_buffer.appendSlice(public_path); + try temp_buffer.appendSlice(bun.strings.removeLeadingDotSlash(output_file.dest_path)); + break :brk temp_buffer.items; + } + break :brk output_file.dest_path; + }, output_file.hash, output_file.loader, output_file.output_kind, diff --git a/src/bundler/LinkerContext.zig b/src/bundler/LinkerContext.zig index 0a80ad2677..3389628624 100644 --- a/src/bundler/LinkerContext.zig +++ b/src/bundler/LinkerContext.zig @@ -841,13 +841,18 @@ pub const LinkerContext = struct { // any import to be considered different if the import's output path has changed. hasher.write(chunk.template.data); + const public_path = if (chunk.is_browser_chunk_from_server_build) + @as(*bundler.BundleV2, @fieldParentPtr("linker", c)).transpilerForTarget(.browser).options.public_path + else + c.options.public_path; + // Also hash the public path. If provided, this is used whenever files // reference each other such as cross-chunk imports, asset file references, // and source map comments. We always include the hash in all chunks instead // of trying to figure out which chunks will include the public path for // simplicity and for robustness to code changes in the future. - if (c.options.public_path.len > 0) { - hasher.write(c.options.public_path); + if (public_path.len > 0) { + hasher.write(public_path); } // Include the generated output content in the hash. This excludes the diff --git a/src/bundler/bundle_v2.zig b/src/bundler/bundle_v2.zig index 9c7c076fdc..b38b65b9c6 100644 --- a/src/bundler/bundle_v2.zig +++ b/src/bundler/bundle_v2.zig @@ -179,27 +179,23 @@ pub const BundleV2 = struct { const this_transpiler = this.transpiler; const client_transpiler = try allocator.create(Transpiler); - const defines = this_transpiler.options.transform_options.define; client_transpiler.* = this_transpiler.*; client_transpiler.options = this_transpiler.options; client_transpiler.options.target = .browser; client_transpiler.options.main_fields = options.Target.DefaultMainFields.get(options.Target.browser); client_transpiler.options.conditions = try options.ESMConditions.init(allocator, options.Target.browser.defaultConditions()); - client_transpiler.options.define = try options.Define.init( - allocator, - if (defines) |user_defines| - try options.Define.Data.fromInput(try options.stringHashMapFromArrays( - options.defines.RawDefines, - allocator, - user_defines.keys, - user_defines.values, - ), this_transpiler.options.transform_options.drop, this_transpiler.log, allocator) - else - null, - null, - this_transpiler.options.define.drop_debugger, - ); + + // We need to make sure it has [hash] in the names so we don't get conflicts. + if (this_transpiler.options.compile) { + client_transpiler.options.asset_naming = bun.options.PathTemplate.asset.data; + client_transpiler.options.chunk_naming = bun.options.PathTemplate.chunk.data; + client_transpiler.options.entry_naming = "./[name]-[hash].[ext]"; + + // Avoid setting a public path for --compile since all the assets + // will be served relative to the server root. + client_transpiler.options.public_path = ""; + } client_transpiler.setLog(this_transpiler.log); client_transpiler.setAllocator(allocator); @@ -207,6 +203,8 @@ pub const BundleV2 = struct { client_transpiler.macro_context = js_ast.Macro.MacroContext.init(client_transpiler); const CacheSet = @import("../cache.zig"); client_transpiler.resolver.caches = CacheSet.Set.init(allocator); + + try client_transpiler.configureDefines(); client_transpiler.resolver.opts = client_transpiler.options; this.client_transpiler = client_transpiler; @@ -1525,8 +1523,12 @@ pub const BundleV2 = struct { else PathTemplate.asset; - if (this.transpiler.options.asset_naming.len > 0) - template.data = this.transpiler.options.asset_naming; + const target = targets[index]; + const asset_naming = this.transpilerForTarget(target).options.asset_naming; + if (asset_naming.len > 0) { + template.data = asset_naming; + } + const source = &sources[index]; const output_path = brk: { @@ -1546,7 +1548,7 @@ pub const BundleV2 = struct { } if (template.needs(.target)) { - template.placeholder.target = @tagName(targets[index]); + template.placeholder.target = @tagName(target); } break :brk std.fmt.allocPrint(bun.default_allocator, "{}", .{template}) catch bun.outOfMemory(); }; diff --git a/src/bundler/linker_context/computeChunks.zig b/src/bundler/linker_context/computeChunks.zig index 2bd7b79d2f..66a0be8720 100644 --- a/src/bundler/linker_context/computeChunks.zig +++ b/src/bundler/linker_context/computeChunks.zig @@ -25,8 +25,11 @@ pub noinline fn computeChunks( const css_chunking = this.options.css_chunking; var html_chunks = bun.StringArrayHashMap(Chunk).init(temp_allocator); const loaders = this.parse_graph.input_files.items(.loader); + const ast_targets = this.graph.ast.items(.target); const code_splitting = this.graph.code_splitting; + const could_be_browser_target_from_server_build = this.options.target.isServerSide() and this.parse_graph.html_imports.html_source_indices.len > 0; + const has_server_html_imports = this.parse_graph.html_imports.server_source_indices.len > 0; // Create chunks for entry points for (entry_source_indices, 0..) |source_index, entry_id_| { @@ -61,6 +64,7 @@ pub noinline fn computeChunks( .entry_bits = entry_bits.*, .content = .html, .output_source_map = sourcemap.SourceMapPieces.init(this.allocator), + .is_browser_chunk_from_server_build = could_be_browser_target_from_server_build and ast_targets[source_index] == .browser, }; } } @@ -95,6 +99,7 @@ pub noinline fn computeChunks( }, .output_source_map = sourcemap.SourceMapPieces.init(this.allocator), .has_html_chunk = has_html_chunk, + .is_browser_chunk_from_server_build = could_be_browser_target_from_server_build and ast_targets[source_index] == .browser, }; } @@ -116,6 +121,7 @@ pub noinline fn computeChunks( }, .has_html_chunk = has_html_chunk, .output_source_map = sourcemap.SourceMapPieces.init(this.allocator), + .is_browser_chunk_from_server_build = could_be_browser_target_from_server_build and ast_targets[source_index] == .browser, }; { @@ -129,7 +135,8 @@ pub noinline fn computeChunks( if (css_source_indices.len > 0) { const order = this.findImportedFilesInCSSOrder(temp_allocator, css_source_indices.slice()); - const hash_to_use = if (!css_chunking) + const use_content_based_key = css_chunking or has_server_html_imports; + const hash_to_use = if (!use_content_based_key) bun.hash(try temp_allocator.dupe(u8, entry_bits.bytes(this.graph.entry_points.len))) else brk: { var hasher = std.hash.Wyhash.init(5); @@ -168,6 +175,7 @@ pub noinline fn computeChunks( .files_with_parts_in_chunk = css_files_with_parts_in_chunk, .output_source_map = sourcemap.SourceMapPieces.init(this.allocator), .has_html_chunk = has_html_chunk, + .is_browser_chunk_from_server_build = could_be_browser_target_from_server_build and ast_targets[source_index] == .browser, }; } } @@ -200,6 +208,7 @@ pub noinline fn computeChunks( var js_chunk_entry = try js_chunks.getOrPut(js_chunk_key); if (!js_chunk_entry.found_existing) { + const is_browser_chunk_from_server_build = could_be_browser_target_from_server_build and ast_targets[source_index.get()] == .browser; js_chunk_entry.value_ptr.* = .{ .entry_bits = entry_bits.*, .entry_point = .{ @@ -209,6 +218,7 @@ pub noinline fn computeChunks( .javascript = .{}, }, .output_source_map = sourcemap.SourceMapPieces.init(this.allocator), + .is_browser_chunk_from_server_build = is_browser_chunk_from_server_build, }; } @@ -305,6 +315,7 @@ pub noinline fn computeChunks( const kinds = this.graph.files.items(.entry_point_kind); const output_paths = this.graph.entry_points.items(.output_path); + const bv2: *bundler.BundleV2 = @fieldParentPtr("linker", this); for (chunks, 0..) |*chunk, chunk_id| { // Assign a unique key to each chunk. This key encodes the index directly so // we can easily recover it later without needing to look it up in a map. The @@ -317,21 +328,27 @@ pub noinline fn computeChunks( (chunk.content == .html or (kinds[chunk.entry_point.source_index] == .user_specified and !chunk.has_html_chunk))) { // Use fileWithTarget template if there are HTML imports and user hasn't manually set naming - if (this.parse_graph.html_imports.server_source_indices.len > 0 and this.resolver.opts.entry_naming.len == 0) { + if (has_server_html_imports and bv2.transpiler.options.entry_naming.len == 0) { chunk.template = PathTemplate.fileWithTarget; } else { chunk.template = PathTemplate.file; - if (this.resolver.opts.entry_naming.len > 0) - chunk.template.data = this.resolver.opts.entry_naming; + if (chunk.is_browser_chunk_from_server_build) { + chunk.template.data = bv2.transpilerForTarget(.browser).options.entry_naming; + } else { + chunk.template.data = bv2.transpiler.options.entry_naming; + } } } else { - if (this.parse_graph.html_imports.server_source_indices.len > 0 and this.resolver.opts.chunk_naming.len == 0) { + if (has_server_html_imports and bv2.transpiler.options.chunk_naming.len == 0) { chunk.template = PathTemplate.chunkWithTarget; } else { chunk.template = PathTemplate.chunk; + if (chunk.is_browser_chunk_from_server_build) { + chunk.template.data = bv2.transpilerForTarget(.browser).options.chunk_naming; + } else { + chunk.template.data = bv2.transpiler.options.chunk_naming; + } } - if (this.resolver.opts.chunk_naming.len > 0) - chunk.template.data = this.resolver.opts.chunk_naming; } const pathname = Fs.PathName.init(output_paths[chunk.entry_point.entry_point_id].slice()); @@ -340,7 +357,6 @@ pub noinline fn computeChunks( if (chunk.template.needs(.target)) { // Determine the target from the AST of the entry point source - const ast_targets = this.graph.ast.items(.target); const chunk_target = ast_targets[chunk.entry_point.source_index]; chunk.template.placeholder.target = switch (chunk_target) { .browser => "browser", diff --git a/src/bundler/linker_context/generateChunksInParallel.zig b/src/bundler/linker_context/generateChunksInParallel.zig index 23a5ba8210..e09ce74aea 100644 --- a/src/bundler/linker_context/generateChunksInParallel.zig +++ b/src/bundler/linker_context/generateChunksInParallel.zig @@ -340,6 +340,8 @@ pub fn generateChunksInParallel(c: *LinkerContext, chunks: []Chunk, comptime is_ return error.MultipleOutputFilesWithoutOutputDir; } + const bundler = @as(*bun.bundle_v2.BundleV2, @fieldParentPtr("linker", c)); + if (root_path.len > 0) { try c.writeOutputFilesToDisk(root_path, chunks, &output_files); } else { @@ -347,11 +349,16 @@ pub fn generateChunksInParallel(c: *LinkerContext, chunks: []Chunk, comptime is_ for (chunks) |*chunk| { var display_size: usize = 0; + const public_path = if (chunk.is_browser_chunk_from_server_build) + bundler.transpilerForTarget(.browser).options.public_path + else + c.options.public_path; + const _code_result = chunk.intermediate_output.code( null, c.parse_graph, &c.graph, - c.resolver.opts.public_path, + public_path, chunk, chunks, &display_size, @@ -376,8 +383,8 @@ pub fn generateChunksInParallel(c: *LinkerContext, chunks: []Chunk, comptime is_ bun.copy(u8, source_map_final_rel_path[chunk.final_rel_path.len..], ".map"); if (tag == .linked) { - const a, const b = if (c.options.public_path.len > 0) - cheapPrefixNormalizer(c.options.public_path, source_map_final_rel_path) + const a, const b = if (public_path.len > 0) + cheapPrefixNormalizer(public_path, source_map_final_rel_path) else .{ "", std.fs.path.basename(source_map_final_rel_path) }; @@ -471,7 +478,7 @@ pub fn generateChunksInParallel(c: *LinkerContext, chunks: []Chunk, comptime is_ .data = .{ .buffer = .{ .data = bytecode, .allocator = cached_bytecode.allocator() }, }, - .side = null, + .side = .server, .entry_point_index = null, .is_executable = false, }); @@ -522,7 +529,7 @@ pub fn generateChunksInParallel(c: *LinkerContext, chunks: []Chunk, comptime is_ .is_executable = chunk.is_executable, .source_map_index = source_map_index, .bytecode_index = bytecode_index, - .side = if (chunk.content == .css) + .side = if (chunk.content == .css or chunk.is_browser_chunk_from_server_build) .client else switch (c.graph.ast.items(.target)[chunk.entry_point.source_index]) { .browser => .client, diff --git a/src/bundler/linker_context/writeOutputFilesToDisk.zig b/src/bundler/linker_context/writeOutputFilesToDisk.zig index 745649726b..d3f5622f5c 100644 --- a/src/bundler/linker_context/writeOutputFilesToDisk.zig +++ b/src/bundler/linker_context/writeOutputFilesToDisk.zig @@ -39,6 +39,7 @@ pub fn writeOutputFilesToDisk( const code_with_inline_source_map_allocator = max_heap_allocator_inline_source_map.init(bun.default_allocator); var pathbuf: bun.PathBuffer = undefined; + const bv2: *bundler.BundleV2 = @fieldParentPtr("linker", c); for (chunks) |*chunk| { const trace2 = bun.perf.trace("Bundler.writeChunkToDisk"); @@ -59,11 +60,16 @@ pub fn writeOutputFilesToDisk( } } var display_size: usize = 0; + const public_path = if (chunk.is_browser_chunk_from_server_build) + bv2.transpilerForTarget(.browser).options.public_path + else + c.resolver.opts.public_path; + var code_result = chunk.intermediate_output.code( code_allocator, c.parse_graph, &c.graph, - c.resolver.opts.public_path, + public_path, chunk, chunks, &display_size, @@ -89,8 +95,8 @@ pub fn writeOutputFilesToDisk( }) catch @panic("Failed to allocate memory for external source map path"); if (tag == .linked) { - const a, const b = if (c.options.public_path.len > 0) - cheapPrefixNormalizer(c.options.public_path, source_map_final_rel_path) + const a, const b = if (public_path.len > 0) + cheapPrefixNormalizer(public_path, source_map_final_rel_path) else .{ "", std.fs.path.basename(source_map_final_rel_path) }; diff --git a/src/cli/build_command.zig b/src/cli/build_command.zig index 225f50af0a..2a42e8898b 100644 --- a/src/cli/build_command.zig +++ b/src/cli/build_command.zig @@ -113,6 +113,7 @@ pub const BuildCommand = struct { } this_transpiler.options.bytecode = ctx.bundler_options.bytecode; + var was_renamed_from_index = false; if (ctx.bundler_options.compile) { if (ctx.bundler_options.code_splitting) { @@ -140,6 +141,7 @@ pub const BuildCommand = struct { if (strings.eqlComptime(outfile, "index")) { outfile = std.fs.path.basename(std.fs.path.dirname(this_transpiler.options.entry_points[0]) orelse "index"); + was_renamed_from_index = !strings.eqlComptime(outfile, "index"); } if (strings.eqlComptime(outfile, "bun")) { @@ -353,7 +355,20 @@ pub const BuildCommand = struct { if (output_dir.len == 0 and outfile.len > 0 and will_be_one_file) { output_dir = std.fs.path.dirname(outfile) orelse "."; - output_files[0].dest_path = std.fs.path.basename(outfile); + if (ctx.bundler_options.compile) { + // If the first output file happens to be a client-side chunk imported server-side + // then don't rename it to something else, since an HTML + // import manifest might depend on the file path being the + // one we think it should be. + for (output_files) |*f| { + if (f.output_kind == .@"entry-point" and (f.side orelse .server) == .server) { + f.dest_path = std.fs.path.basename(outfile); + break; + } + } + } else { + output_files[0].dest_path = std.fs.path.basename(outfile); + } } if (!ctx.bundler_options.compile) { @@ -416,6 +431,11 @@ pub const BuildCommand = struct { if (compile_target.os == .windows and !strings.hasSuffixComptime(outfile, ".exe")) { outfile = try std.fmt.allocPrint(allocator, "{s}.exe", .{outfile}); + } else if (was_renamed_from_index and !bun.strings.eqlComptime(outfile, "index")) { + // If we're going to fail due to EISDIR, we should instead pick a different name. + if (bun.sys.directoryExistsAt(bun.FD.fromStdDir(root_dir), outfile).asValue() orelse false) { + outfile = "index"; + } } try bun.StandaloneModuleGraph.toExecutable( diff --git a/src/sys.zig b/src/sys.zig index 19eb84d83d..6d8e1e71c4 100644 --- a/src/sys.zig +++ b/src/sys.zig @@ -3434,11 +3434,17 @@ pub fn existsAtType(fd: bun.FileDescriptor, subpath: anytype) Maybe(ExistsAtType if (comptime Environment.isWindows) { const wbuf = bun.WPathBufferPool.get(); defer bun.WPathBufferPool.put(wbuf); - const path = if (std.meta.Child(@TypeOf(subpath)) == u16) + var path = if (std.meta.Child(@TypeOf(subpath)) == u16) bun.strings.toNTPath16(wbuf, subpath) else bun.strings.toNTPath(wbuf, subpath); + // trim leading .\ + // NtQueryAttributesFile expects relative paths to not start with .\ + if (path.len > 2 and path[0] == '.' and path[1] == '\\') { + path = path[2..]; + } + const path_len_bytes: u16 = @truncate(path.len * 2); var nt_name = w.UNICODE_STRING{ .Length = path_len_bytes, diff --git a/test/bundler/bundler_html_server.test.ts b/test/bundler/bundler_html_server.test.ts new file mode 100644 index 0000000000..122709df27 --- /dev/null +++ b/test/bundler/bundler_html_server.test.ts @@ -0,0 +1,121 @@ +import { describe } from "bun:test"; +import { itBundled } from "./expectBundled"; + +describe("bundler", () => { + itBundled("compile/HTMLServerBasic", { + compile: true, + files: { + "/entry.ts": /* js */ ` + import index from "./index.html"; + + using server = Bun.serve({ + port: 0, + routes: { + "/": index, + }, + }); + + const res = await fetch(server.url); + console.log("Status:", res.status); + console.log("Content-Type:", res.headers.get("content-type")); + + const html = await res.text(); + console.log("Has HTML tag:", html.includes("")); + console.log("Has h1:", html.includes("Hello HTML")); + + `, + "/index.html": /* html */ ` + + + + Test Page + + + +

Hello HTML

+ + + + `, + "/styles.css": /* css */ ` + body { + background: blue; + } + `, + "/app.js": /* js */ ` + console.log("Client app loaded"); + `, + }, + run: { + stdout: "Status: 200\nContent-Type: text/html;charset=utf-8\nHas HTML tag: true\nHas h1: true", + }, + }); + + itBundled("compile/HTMLServerMultipleRoutes", { + compile: true, + files: { + "/entry.ts": /* js */ ` + import home from "./home.html"; + import about from "./about.html"; + + using server = Bun.serve({ + port: 0, + routes: { + "/": home, + "/about": about, + }, + }); + + // Test home route + const homeRes = await fetch(server.url); + console.log("Home status:", homeRes.status); + const homeHtml = await homeRes.text(); + console.log("Home has content:", homeHtml.includes("Home Page")); + + // Test about route + const aboutRes = await fetch(server.url + "about"); + console.log("About status:", aboutRes.status); + const aboutHtml = await aboutRes.text(); + console.log("About has content:", aboutHtml.includes("About Page")); + `, + "/home.html": /* html */ ` + + + + Home + + + +

Home Page

+ + + + `, + "/about.html": /* html */ ` + + + + About + + + +

About Page

+ + + + `, + "/styles.css": /* css */ ` + body { + margin: 0; + font-family: sans-serif; + } + `, + "/app.js": /* js */ ` + console.log("App loaded"); + `, + }, + run: { + stdout: "Home status: 200\nHome has content: true\nAbout status: 200\nAbout has content: true", + }, + }); +}); diff --git a/test/internal/ban-words.test.ts b/test/internal/ban-words.test.ts index f840f0987b..ac05275e59 100644 --- a/test/internal/ban-words.test.ts +++ b/test/internal/ban-words.test.ts @@ -44,7 +44,7 @@ const words: Record ".arguments_old(": { reason: "Please migrate to .argumentsAsArray() or another argument API", limit: 284 }, "// autofix": { reason: "Evaluate if this variable should be deleted entirely or explicitly discarded.", limit: 175 }, - "global.hasException": { reason: "Incompatible with strict exception checks. Use a CatchScope instead.", limit: 28 }, + "global.hasException": { reason: "Incompatible with strict exception checks. Use a CatchScope instead.", limit: 29 }, "globalObject.hasException": { reason: "Incompatible with strict exception checks. Use a CatchScope instead.", limit: 49 }, "globalThis.hasException": { reason: "Incompatible with strict exception checks. Use a CatchScope instead.", limit: 139 }, }; diff --git a/test/js/bun/http/bun-serve-html-manifest.test.ts b/test/js/bun/http/bun-serve-html-manifest.test.ts new file mode 100644 index 0000000000..939489eefe --- /dev/null +++ b/test/js/bun/http/bun-serve-html-manifest.test.ts @@ -0,0 +1,372 @@ +import { describe, expect, it } from "bun:test"; +import { bunEnv, bunExe, rmScope, tempDirWithFiles } from "harness"; +import { join } from "node:path"; +import { StringDecoder } from "node:string_decoder"; + +describe("Bun.serve HTML manifest", () => { + it("serves HTML import with manifest", async () => { + const dir = tempDirWithFiles("serve-html", { + "server.ts": ` + import index from "./index.html"; + + const server = Bun.serve({ + port: 0, + routes: { + "/": index, + }, + }); + + console.log("PORT=" + server.port); + + // Test the manifest structure + console.log("Manifest type:", typeof index); + console.log("Has index:", "index" in index); + console.log("Has files:", "files" in index); + if (index.files) { + console.log("File count:", index.files.length); + } + `, + "index.html": ` + + + Test + + + +

Hello World

+ + +`, + "styles.css": `body { background: red; }`, + "app.js": `console.log("Hello from app");`, + }); + + using cleanup = { [Symbol.dispose]: () => rmScope(dir) }; + + const proc = Bun.spawn({ + cmd: [bunExe(), "run", join(dir, "server.ts")], + cwd: dir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + + const { stdout, stderr, exited } = proc; + + // Read stdout line by line until we get the PORT + let port: number | undefined; + const reader = stdout.getReader(); + const decoder = new StringDecoder("utf8"); + let buffer = ""; + + while (!port) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.write(value); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + const portMatch = line.match(/PORT=(\d+)/); + if (portMatch) { + port = parseInt(portMatch[1]); + break; + } + } + } + + reader.releaseLock(); + expect(port).toBeDefined(); + + if (port) { + // Test the server + const res = await fetch(`http://localhost:${port}/`); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + + const html = await res.text(); + expect(html).toContain("Hello World"); + expect(html).toContain(" { + const dir = tempDirWithFiles("serve-html-bundled", { + "build.ts": ` + const result = await Bun.build({ + entrypoints: ["./server.ts"], + target: "bun", + outdir: "./dist", + }); + + if (!result.success) { + console.error("Build failed"); + process.exit(1); + } + + console.log("Build complete"); + `, + "server.ts": ` + import index from "./index.html"; + import about from "./about.html"; + + const server = Bun.serve({ + port: 0, + routes: { + "/": index, + "/about": about, + }, + }); + + console.log("PORT=" + server.port); + `, + "index.html": ` + + + Home + + + +

Home Page

+ + +`, + "about.html": ` + + + About + + + +

About Page

+ + +`, + "shared.css": `body { margin: 0; }`, + "app.js": `console.log("App loaded");`, + }); + + using cleanup = { [Symbol.dispose]: () => rmScope(dir) }; + + // Build first + const buildProc = Bun.spawn({ + cmd: [bunExe(), "run", join(dir, "build.ts")], + cwd: dir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + + await buildProc.exited; + expect(buildProc.exitCode).toBe(0); + + // Run the built server + const serverProc = Bun.spawn({ + cmd: [bunExe(), "run", join(dir, "dist", "server.js")], + cwd: join(dir, "dist"), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + + // Read stdout line by line until we get the PORT + let port: number | undefined; + const reader = serverProc.stdout.getReader(); + const decoder = new StringDecoder("utf8"); + let buffer = ""; + + while (!port) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.write(value); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + const portMatch = line.match(/PORT=(\d+)/); + if (portMatch) { + port = parseInt(portMatch[1]); + break; + } + } + } + + reader.releaseLock(); + expect(port).toBeDefined(); + + if (port) { + // Test both routes + const homeRes = await fetch(`http://localhost:${port}/`); + expect(homeRes.status).toBe(200); + const homeHtml = await homeRes.text(); + expect(homeHtml).toContain("Home Page"); + + const aboutRes = await fetch(`http://localhost:${port}/about`); + expect(aboutRes.status).toBe(200); + const aboutHtml = await aboutRes.text(); + expect(aboutHtml).toContain("About Page"); + } + + serverProc.kill(); + await serverProc.exited; + }); + + it("validates manifest files exist", async () => { + const dir = tempDirWithFiles("serve-html-validate", { + "test.ts": ` + // Create a fake manifest + const fakeManifest = { + index: "./index.html", + files: [ + { + input: "index.html", + path: "./does-not-exist.html", + loader: "html", + isEntry: true, + headers: { + etag: "test123", + "content-type": "text/html;charset=utf-8" + } + } + ] + }; + + try { + const server = Bun.serve({ + port: 0, + routes: { + "/": fakeManifest, + }, + }); + console.log("ERROR: Server started when it should have failed"); + server.stop(); + } catch (error) { + console.log("SUCCESS: Manifest validation failed as expected"); + } + `, + }); + + using cleanup = { [Symbol.dispose]: () => rmScope(dir) }; + + const proc = Bun.spawn({ + cmd: [bunExe(), "run", join(dir, "test.ts")], + cwd: dir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + + const out = await new Response(proc.stdout).text(); + await proc.exited; + + expect(out).toContain("SUCCESS: Manifest validation failed as expected"); + }); + + it("serves manifest with proper headers", async () => { + const dir = tempDirWithFiles("serve-html-headers", { + "server.ts": ` + import index from "./index.html"; + + const server = Bun.serve({ + port: 0, + routes: { + "/": index, + }, + }); + + console.log("PORT=" + server.port); + + // Check manifest structure + if (index.files) { + for (const file of index.files) { + console.log("File:", file.path, "Loader:", file.loader); + if (file.headers) { + console.log(" Content-Type:", file.headers["content-type"]); + console.log(" Has ETag:", !!file.headers.etag); + } + } + } + `, + "index.html": ` + + + Test + + + +

Test

+ +`, + "test.css": `h1 { color: red; }`, + }); + + using cleanup = { [Symbol.dispose]: () => rmScope(dir) }; + + // Build first to generate the manifest + const buildProc = Bun.spawn({ + cmd: [bunExe(), "build", join(dir, "server.ts"), "--outdir", join(dir, "dist"), "--target", "bun"], + cwd: dir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + + await buildProc.exited; + expect(buildProc.exitCode).toBe(0); + + // Run the built server + const proc = Bun.spawn({ + cmd: [bunExe(), "run", join(dir, "dist", "server.js")], + cwd: join(dir, "dist"), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + + // Read stdout line by line to collect all output + const reader = proc.stdout.getReader(); + const decoder = new StringDecoder("utf8"); + let buffer = ""; + let output = ""; + let etagCount = 0; + const expectedEtagLines = 2; // One for HTML, one for CSS + + while (etagCount < expectedEtagLines) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.write(value); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + output += line + "\n"; + if (line.includes("Has ETag:")) { + etagCount++; + } + } + } + + reader.releaseLock(); + + // Should have proper content types + expect(output).toContain("text/html"); + expect(output).toContain("text/css"); + expect(output).toContain("Has ETag:"); + + proc.kill(); + await proc.exited; + }); +}); diff --git a/test/no-validate-exceptions.txt b/test/no-validate-exceptions.txt index b313ae40dc..39d038dc19 100644 --- a/test/no-validate-exceptions.txt +++ b/test/no-validate-exceptions.txt @@ -29,6 +29,8 @@ test/bundler/css/wpt/relative_color_out_of_gamut.test.ts test/bundler/esbuild/css.test.ts test/bundler/esbuild/dce.test.ts test/bundler/esbuild/extra.test.ts +test/bundler/bundler_html_server.test.ts +test/js/bun/http/bun-serve-html-manifest.test.ts test/bundler/esbuild/importstar_ts.test.ts test/bundler/esbuild/importstar.test.ts test/bundler/esbuild/loader.test.ts