Files
bun.sh/src/bundler/linker_context/OutputFileListBuilder.zig
robobun b817abe55e feat(bundler): add --compile --target=browser for self-contained HTML output (#27056)
## Summary
- Adds self-contained HTML output mode: `--compile --target=browser`
(CLI) or `compile: true, target: "browser"` (`Bun.build()` API)
- Produces HTML files with all JS, CSS, and assets inlined directly:
`<script src="...">` → inline `<script>`, `<link rel="stylesheet">` →
inline `<style>`, asset references → `data:` URIs
- All entrypoints must be `.html` files when using `--compile
--target=browser`
- Validates: errors if any entrypoints aren't HTML, or if `--splitting`
is used
- Useful for distributing `.html` files that work via `file://` URLs
without needing a web server or worrying about CORS restrictions

## Test plan
- [x] Added `test/bundler/standalone.test.ts` covering:
  - Basic JS inlining into HTML
  - CSS inlining into HTML  
  - Combined JS + CSS inlining
  - Asset inlining as data URIs
  - CSS `url()` references inlined as data URIs
  - Validation: non-HTML entrypoints rejected
  - Validation: mixed HTML/non-HTML entrypoints rejected
  - Validation: splitting rejected
  - `Bun.build()` API with `compile: true, target: "browser"`
  - CLI `--compile --target=browser`
  - Minification works with compile+browser

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-02-17 15:27:36 -08:00

151 lines
6.5 KiB
Zig

//! Q: What does this struct do?
//! A: This struct segments the `OutputFile` list into 3 separate spaces so
//! chunk indexing remains the same:
//!
//! 1. chunks
//! 2. sourcemaps, bytecode, and module_info
//! 3. additional output files
//!
//! We can calculate the space ahead of time and avoid having to do something
//! more complicated or which requires extra work.
//!
//! Q: Why does it need to do that?
//! A: We would like it so if we have a chunk index, we can also index its
//! corresponding output file in the output file list.
//!
//! The DevServer uses the `referenced_css_chunks` (a list of chunk indices)
//! field on `OutputFile` to know which CSS files to hand to the rendering
//! function. For React this just adds <link> tags that point to each output CSS
//! file.
//!
//! However, we previously were pushing sourcemaps and bytecode output files
//! to the output file list directly after their corresponding chunk, meaning
//! the index of the chunk in the chunk list and its corresponding
//! `OutputFile` in the output file list got scrambled.
//!
//! If we maintain the property that `outputIndexForChunk(chunk[i]) == i`
//! then we don't need to do any allocations or extra work to get the output
//! file for a chunk.
pub const OutputFileList = @This();
output_files: std.array_list.Managed(options.OutputFile),
index_for_chunk: u32,
index_for_sourcemaps_and_bytecode: ?u32,
additional_output_files_start: u32,
total_insertions: u32,
pub fn init(
allocator: std.mem.Allocator,
c: *const bun.bundle_v2.LinkerContext,
chunks: []const bun.bundle_v2.Chunk,
_: usize,
) !@This() {
const length, const supplementary_file_count = OutputFileList.calculateOutputFileListCapacity(c, chunks);
var output_files = try std.array_list.Managed(options.OutputFile).initCapacity(
allocator,
length,
);
output_files.appendNTimesAssumeCapacity(OutputFile.zero_value, length);
return .{
.output_files = output_files,
.index_for_chunk = 0,
.index_for_sourcemaps_and_bytecode = if (supplementary_file_count == 0) null else @as(u32, @truncate(chunks.len)),
.additional_output_files_start = @as(u32, @intCast(chunks.len)) + supplementary_file_count,
.total_insertions = 0,
};
}
pub fn take(this: *@This()) std.array_list.Managed(options.OutputFile) {
// TODO: should this return an error
bun.assertf(this.total_insertions == this.output_files.items.len, "total_insertions ({d}) != output_files.items.len ({d})", .{ this.total_insertions, this.output_files.items.len });
// Set the length just in case so the list doesn't have undefined memory
this.output_files.items.len = this.total_insertions;
const list = this.output_files;
this.output_files = std.array_list.Managed(options.OutputFile).init(bun.default_allocator);
return list;
}
pub fn calculateOutputFileListCapacity(c: *const bun.bundle_v2.LinkerContext, chunks: []const bun.bundle_v2.Chunk) struct { u32, u32 } {
const source_map_count = if (c.options.source_maps.hasExternalFiles()) brk: {
var count: usize = 0;
for (chunks) |*chunk| {
if (chunk.content.sourcemap(c.options.source_maps).hasExternalFiles()) {
count += 1;
}
}
break :brk count;
} else 0;
const bytecode_count = if (c.options.generate_bytecode_cache) bytecode_count: {
var bytecode_count: usize = 0;
for (chunks) |*chunk| {
const loader: bun.options.Loader = if (chunk.entry_point.is_entry_point)
c.parse_graph.input_files.items(.loader)[
chunk.entry_point.source_index
]
else
.js;
if (chunk.content == .javascript and loader.isJavaScriptLike()) {
bytecode_count += 1;
}
}
break :bytecode_count bytecode_count;
} else 0;
// module_info is generated for ESM bytecode in --compile builds
const module_info_count = if (c.options.generate_bytecode_cache and c.options.output_format == .esm and c.options.compile) bytecode_count else 0;
const additional_output_files_count = if (c.options.compile_to_standalone_html) 0 else c.parse_graph.additional_output_files.items.len;
return .{ @intCast(chunks.len + source_map_count + bytecode_count + module_info_count + additional_output_files_count), @intCast(source_map_count + bytecode_count + module_info_count) };
}
pub fn insertForChunk(this: *OutputFileList, output_file: options.OutputFile) u32 {
const index = this.indexForChunk();
bun.assertf(index < this.index_for_sourcemaps_and_bytecode orelse std.math.maxInt(u32), "index ({d}) \\< index_for_sourcemaps_and_bytecode ({d})", .{ index, this.index_for_sourcemaps_and_bytecode orelse std.math.maxInt(u32) });
this.output_files.items[index] = output_file;
this.total_insertions += 1;
return index;
}
pub fn insertForSourcemapOrBytecode(this: *OutputFileList, output_file: options.OutputFile) !u32 {
const index = this.indexForSourcemapOrBytecode() orelse return error.NoSourceMapsOrBytecode;
bun.assertf(index < this.additional_output_files_start, "index ({d}) \\< additional_output_files_start ({d})", .{ index, this.additional_output_files_start });
this.output_files.items[index] = output_file;
this.total_insertions += 1;
return index;
}
pub fn insertAdditionalOutputFiles(this: *OutputFileList, additional_output_files: []const options.OutputFile) void {
bun.assertf(this.index_for_sourcemaps_and_bytecode orelse 0 <= this.additional_output_files_start, "index_for_sourcemaps_and_bytecode ({d}) \\< additional_output_files_start ({d})", .{ this.index_for_sourcemaps_and_bytecode orelse 0, this.additional_output_files_start });
bun.copy(
options.OutputFile,
this.getMutableAdditionalOutputFiles(),
additional_output_files,
);
this.total_insertions += @as(u32, @intCast(additional_output_files.len));
}
pub fn getMutableAdditionalOutputFiles(this: *OutputFileList) []options.OutputFile {
return this.output_files.items[this.additional_output_files_start..];
}
fn indexForChunk(this: *@This()) u32 {
const result = this.index_for_chunk;
this.index_for_chunk += 1;
return result;
}
fn indexForSourcemapOrBytecode(this: *@This()) ?u32 {
const result = this.index_for_sourcemaps_and_bytecode orelse return null;
this.index_for_sourcemaps_and_bytecode.? += 1;
return result;
}
const bun = @import("bun");
const std = @import("std");
const options = bun.options;
const OutputFile = options.OutputFile;