mirror of
https://github.com/oven-sh/bun
synced 2026-02-10 02:48:50 +00:00
- Update GetResult struct to use MappingsData union instead of extracting .list - Add generated() method to MappingsData that returns empty array for compact format - Fix toMapping to properly branch between coverage (full format) and non-coverage (compact) - Remove pointless variable discards that caused compilation errors - Error reporting now uses compact format and does on-demand VLQ decoding via find() This ensures compact sourcemaps are only expanded to full format when coverage analysis is enabled, providing memory savings for normal error reporting scenarios. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
524 lines
20 KiB
Zig
524 lines
20 KiB
Zig
/// Storage for source maps on `/_bun/client/{id}.js.map`
|
|
///
|
|
/// All source maps are referenced counted, so that when a websocket disconnects
|
|
/// or a bundle is replaced, the unreachable source map URLs are revoked. Source
|
|
/// maps that aren't reachable from IncrementalGraph can still be reached by
|
|
/// a browser tab if it has a callback to a previously loaded chunk; so DevServer
|
|
/// should be aware of it.
|
|
pub const SourceMapStore = @This();
|
|
|
|
/// See `SourceId` for what the content of u64 is.
|
|
pub const Key = bun.GenericIndex(u64, .{ "Key of", SourceMapStore });
|
|
|
|
entries: AutoArrayHashMapUnmanaged(Key, Entry),
|
|
/// When a HTML bundle is loaded, it places a "weak reference" to the
|
|
/// script's source map. This reference is held until either:
|
|
/// - The script loads and moves the ref into "strongly held" by the HmrSocket
|
|
/// - The expiry time passes
|
|
/// - Too many different weak references exist
|
|
weak_refs: bun.LinearFifo(WeakRef, .{ .Static = weak_ref_entry_max }),
|
|
/// Shared
|
|
weak_ref_sweep_timer: EventLoopTimer,
|
|
|
|
pub const empty: SourceMapStore = .{
|
|
.entries = .empty,
|
|
.weak_ref_sweep_timer = .initPaused(.DevServerSweepSourceMaps),
|
|
.weak_refs = .init(),
|
|
};
|
|
const weak_ref_expiry_seconds = 10;
|
|
const weak_ref_entry_max = 16;
|
|
|
|
/// Route bundle keys clear the bottom 32 bits of this value, using only the
|
|
/// top 32 bits to represent the map. For JS chunks, these bottom 32 bits are
|
|
/// used as an index into `dev.route_bundles` to know what route it refers to.
|
|
///
|
|
/// HMR patches set the bottom bit to `1`, and use the remaining 63 bits as
|
|
/// an ID. This is fine since the JS chunks are never served after the update
|
|
/// is emitted.
|
|
// TODO: Rewrite this `SourceMapStore.Key` and some other places that use bit
|
|
// shifts and u64 to use this struct.
|
|
pub const SourceId = packed struct(u64) {
|
|
kind: ChunkKind,
|
|
bits: packed union {
|
|
initial_response: packed struct(u63) {
|
|
unused: enum(u31) { zero = 0 } = .zero,
|
|
generation_id: u32,
|
|
},
|
|
hmr_chunk: packed struct(u63) {
|
|
content_hash: u63,
|
|
},
|
|
},
|
|
};
|
|
|
|
/// IncrementalGraph stores partial source maps for each file. A
|
|
/// `SourceMapStore.Entry` is the information + refcount holder to
|
|
/// construct the actual JSON file associated with a bundle/hot update.
|
|
pub const Entry = struct {
|
|
/// Sum of:
|
|
/// - How many active sockets have code that could reference this source map?
|
|
/// - For route bundle client scripts, +1 until invalidation.
|
|
ref_count: u32,
|
|
/// Indexes are off by one because this excludes the HMR Runtime.
|
|
/// Outer slice is owned, inner slice is shared with IncrementalGraph.
|
|
paths: []const []const u8,
|
|
/// Indexes are off by one because this excludes the HMR Runtime.
|
|
files: bun.MultiArrayList(PackedMap.RefOrEmpty),
|
|
/// The memory cost can be shared between many entries and IncrementalGraph
|
|
/// So this is only used for eviction logic, to pretend this was the only
|
|
/// entry. To compute the memory cost of DevServer, this cannot be used.
|
|
overlapping_memory_cost: u32,
|
|
|
|
pub fn sourceContents(entry: @This()) []const bun.StringPointer {
|
|
return entry.source_contents[0..entry.file_paths.len];
|
|
}
|
|
|
|
pub fn renderMappings(map: Entry, kind: ChunkKind, arena: Allocator, gpa: Allocator) ![]u8 {
|
|
var j: StringJoiner = .{ .allocator = arena };
|
|
j.pushStatic("AAAA");
|
|
try joinVLQ(&map, kind, &j, arena);
|
|
return j.done(gpa);
|
|
}
|
|
|
|
pub fn renderJSON(map: *const Entry, dev: *DevServer, arena: Allocator, kind: ChunkKind, gpa: Allocator) ![]u8 {
|
|
const map_files = map.files.slice();
|
|
const paths = map.paths;
|
|
|
|
var j: StringJoiner = .{ .allocator = arena };
|
|
|
|
j.pushStatic(
|
|
\\{"version":3,"sources":["bun://Bun/Bun HMR Runtime"
|
|
);
|
|
|
|
// This buffer is temporary, holding the quoted source paths, joined with commas.
|
|
var source_map_strings = std.ArrayList(u8).init(arena);
|
|
defer source_map_strings.deinit();
|
|
|
|
const buf = bun.path_buffer_pool.get();
|
|
defer bun.path_buffer_pool.put(buf);
|
|
|
|
for (paths) |native_file_path| {
|
|
try source_map_strings.appendSlice(",");
|
|
const path = if (Environment.isWindows)
|
|
bun.path.pathToPosixBuf(u8, native_file_path, buf)
|
|
else
|
|
native_file_path;
|
|
|
|
if (std.fs.path.isAbsolute(path)) {
|
|
const is_windows_drive_path = Environment.isWindows and path[0] != '/';
|
|
try source_map_strings.appendSlice(if (is_windows_drive_path)
|
|
"\"file:///"
|
|
else
|
|
"\"file://");
|
|
if (Environment.isWindows and !is_windows_drive_path) {
|
|
// UNC namespace -> file://server/share/path.ext
|
|
bun.strings.percentEncodeWrite(
|
|
if (path.len > 2 and path[0] == '/' and path[1] == '/')
|
|
path[2..]
|
|
else
|
|
path, // invalid but must not crash
|
|
&source_map_strings,
|
|
) catch |err| switch (err) {
|
|
error.IncompleteUTF8 => @panic("Unexpected: asset with incomplete UTF-8 as file path"),
|
|
error.OutOfMemory => |e| return e,
|
|
};
|
|
} else {
|
|
// posix paths always start with '/'
|
|
// -> file:///path/to/file.js
|
|
// windows drive letter paths have the extra slash added
|
|
// -> file:///C:/path/to/file.js
|
|
bun.strings.percentEncodeWrite(path, &source_map_strings) catch |err| switch (err) {
|
|
error.IncompleteUTF8 => @panic("Unexpected: asset with incomplete UTF-8 as file path"),
|
|
error.OutOfMemory => |e| return e,
|
|
};
|
|
}
|
|
try source_map_strings.appendSlice("\"");
|
|
} else {
|
|
try source_map_strings.appendSlice("\"bun://");
|
|
bun.strings.percentEncodeWrite(path, &source_map_strings) catch |err| switch (err) {
|
|
error.IncompleteUTF8 => @panic("Unexpected: asset with incomplete UTF-8 as file path"),
|
|
error.OutOfMemory => |e| return e,
|
|
};
|
|
try source_map_strings.appendSlice("\"");
|
|
}
|
|
}
|
|
j.pushStatic(source_map_strings.items);
|
|
j.pushStatic(
|
|
\\],"sourcesContent":["// (Bun's internal HMR runtime is minified)"
|
|
);
|
|
for (map_files.items(.tags), map_files.items(.data)) |tag, chunk| {
|
|
// For empty chunks, put a blank entry. This allows HTML
|
|
// files to get their stack remapped, despite having no
|
|
// actual mappings.
|
|
if (tag == .empty) {
|
|
j.pushStatic(",\"\"");
|
|
continue;
|
|
}
|
|
j.pushStatic(",");
|
|
const quoted_slice = chunk.ref.data.quotedContents();
|
|
if (quoted_slice.len == 0) {
|
|
bun.debugAssert(false); // vlq without source contents!
|
|
j.pushStatic(",\"// Did not have source contents for this file.\n// This is a bug in Bun's bundler and should be reported with a reproduction.\"");
|
|
continue;
|
|
}
|
|
// Store the location of the source file. Since it is going
|
|
// to be stored regardless for use by the served source map.
|
|
// These 8 bytes per file allow remapping sources without
|
|
// reading from disk, as well as ensuring that remaps to
|
|
// this exact sourcemap can print the previous state of
|
|
// the code when it was modified.
|
|
bun.assert(quoted_slice[0] == '"');
|
|
bun.assert(quoted_slice[quoted_slice.len - 1] == '"');
|
|
j.pushStatic(quoted_slice);
|
|
}
|
|
// This first mapping makes the bytes from line 0 column 0 to the next mapping
|
|
j.pushStatic(
|
|
\\],"names":[],"mappings":"AAAA
|
|
);
|
|
try joinVLQ(map, kind, &j, arena);
|
|
|
|
const json_bytes = try j.doneWithEnd(gpa, "\"}");
|
|
errdefer @compileError("last try should be the final alloc");
|
|
|
|
if (bun.FeatureFlags.bake_debugging_features) if (dev.dump_dir) |dump_dir| {
|
|
const rel_path_escaped = "latest_chunk.js.map";
|
|
dumpBundle(dump_dir, .client, rel_path_escaped, json_bytes, false) catch |err| {
|
|
bun.handleErrorReturnTrace(err, @errorReturnTrace());
|
|
Output.warn("Could not dump bundle: {}", .{err});
|
|
};
|
|
};
|
|
|
|
return json_bytes;
|
|
}
|
|
|
|
fn joinVLQ(map: *const Entry, kind: ChunkKind, j: *StringJoiner, arena: Allocator) !void {
|
|
const map_files = map.files.slice();
|
|
|
|
const runtime: bake.HmrRuntime = switch (kind) {
|
|
.initial_response => bun.bake.getHmrRuntime(.client),
|
|
.hmr_chunk => comptime .init("self[Symbol.for(\"bun:hmr\")]({\n"),
|
|
};
|
|
|
|
var prev_end_state: SourceMap.SourceMapState = .{
|
|
.generated_line = 0,
|
|
.generated_column = 0,
|
|
.source_index = 0,
|
|
.original_line = 0,
|
|
.original_column = 0,
|
|
};
|
|
|
|
// +2 because the magic fairy in my dreams said it would align the source maps.
|
|
var lines_between: u32 = runtime.line_count + 2;
|
|
|
|
// Join all of the mappings together.
|
|
for (map_files.items(.tags), map_files.items(.data), 1..) |tag, chunk, source_index| switch (tag) {
|
|
.empty => {
|
|
lines_between += (chunk.empty.line_count.unwrap() orelse
|
|
// NOTE: It is too late to compute this info since the
|
|
// bundled text may have been freed already. For example, a
|
|
// HMR chunk is never persisted.
|
|
@panic("Missing internal precomputed line count.")).get();
|
|
|
|
// - Empty file has no breakpoints that could remap.
|
|
// - Codegen of HTML files cannot throw.
|
|
continue;
|
|
},
|
|
.ref => {
|
|
const content = chunk.ref.data;
|
|
const start_state: SourceMap.SourceMapState = .{
|
|
.source_index = @intCast(source_index),
|
|
.generated_line = @intCast(lines_between),
|
|
.generated_column = 0,
|
|
.original_line = 0,
|
|
.original_column = 0,
|
|
};
|
|
lines_between = 0;
|
|
|
|
try SourceMap.appendSourceMapChunk(
|
|
j,
|
|
arena,
|
|
prev_end_state,
|
|
start_state,
|
|
content.vlq(),
|
|
);
|
|
|
|
prev_end_state = .{
|
|
.source_index = @intCast(source_index),
|
|
.generated_line = 0,
|
|
.generated_column = 0,
|
|
.original_line = content.end_state.original_line,
|
|
.original_column = content.end_state.original_column,
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
pub fn deinit(entry: *Entry, dev: *DevServer) void {
|
|
_ = VoidFieldTypes(Entry){
|
|
.ref_count = assert(entry.ref_count == 0),
|
|
.overlapping_memory_cost = {},
|
|
.files = {
|
|
for (entry.files.items(.tags), entry.files.items(.data)) |tag, data| {
|
|
switch (tag) {
|
|
.ref => data.ref.derefWithContext(dev),
|
|
.empty => {},
|
|
}
|
|
}
|
|
entry.files.deinit(dev.allocator);
|
|
},
|
|
.paths = dev.allocator.free(entry.paths),
|
|
};
|
|
}
|
|
};
|
|
|
|
pub const WeakRef = struct {
|
|
/// This encoding only supports route bundle scripts, which do not
|
|
/// utilize the bottom 32 bits of their keys. This is because the bottom
|
|
/// 32 bits are used for the index of the route bundle. While those bits
|
|
/// are present in the JS file's key, it is not present in the source
|
|
/// map key. This allows this struct to be cleanly packed to 128 bits.
|
|
key_top_bits: u32,
|
|
/// When this ref expires, it must subtract this many from `refs`
|
|
count: u32,
|
|
/// Seconds since epoch. Every time `weak_refs` is incremented, this is
|
|
/// updated to the current time + 1 minute. When the timer expires, all
|
|
/// references are removed.
|
|
expire: i64,
|
|
|
|
pub fn key(ref: WeakRef) Key {
|
|
return .init(@as(u64, ref.key_top_bits) << 32);
|
|
}
|
|
|
|
pub fn init(k: Key, count: u32, expire: i64) WeakRef {
|
|
return .{
|
|
.key_top_bits = @intCast(k.get() >> 32),
|
|
.count = count,
|
|
.expire = expire,
|
|
};
|
|
}
|
|
};
|
|
|
|
pub fn owner(store: *SourceMapStore) *DevServer {
|
|
return @alignCast(@fieldParentPtr("source_maps", store));
|
|
}
|
|
|
|
const PutOrIncrementRefCount = union(enum) {
|
|
/// If an *Entry is returned, caller must initialize some
|
|
/// fields with the source map data.
|
|
uninitialized: *Entry,
|
|
/// Already exists, ref count was incremented.
|
|
shared: *Entry,
|
|
};
|
|
pub fn putOrIncrementRefCount(store: *SourceMapStore, script_id: Key, ref_count: u32) !PutOrIncrementRefCount {
|
|
const gop = try store.entries.getOrPut(store.owner().allocator, script_id);
|
|
if (!gop.found_existing) {
|
|
bun.debugAssert(ref_count > 0); // invalid state
|
|
gop.value_ptr.* = .{
|
|
.ref_count = ref_count,
|
|
.overlapping_memory_cost = undefined,
|
|
.paths = undefined,
|
|
.files = undefined,
|
|
};
|
|
return .{ .uninitialized = gop.value_ptr };
|
|
} else {
|
|
bun.debugAssert(ref_count >= 0); // okay since ref_count is already 1
|
|
gop.value_ptr.*.ref_count += ref_count;
|
|
return .{ .shared = gop.value_ptr };
|
|
}
|
|
}
|
|
|
|
pub fn unref(store: *SourceMapStore, key: Key) void {
|
|
unrefCount(store, key, 1);
|
|
}
|
|
|
|
pub fn unrefCount(store: *SourceMapStore, key: Key, count: u32) void {
|
|
const index = store.entries.getIndex(key) orelse
|
|
return bun.debugAssert(false);
|
|
unrefAtIndex(store, index, count);
|
|
}
|
|
|
|
fn unrefAtIndex(store: *SourceMapStore, index: usize, count: u32) void {
|
|
const e = &store.entries.values()[index];
|
|
e.ref_count -= count;
|
|
if (bun.Environment.enable_logs) {
|
|
mapLog("dec {x}, {d} | {d} -> {d}", .{ store.entries.keys()[index].get(), count, e.ref_count + count, e.ref_count });
|
|
}
|
|
if (e.ref_count == 0) {
|
|
e.deinit(store.owner());
|
|
store.entries.swapRemoveAt(index);
|
|
}
|
|
}
|
|
|
|
pub fn addWeakRef(store: *SourceMapStore, key: Key) void {
|
|
// This function expects that `weak_ref_entry_max` is low.
|
|
const entry = store.entries.getPtr(key) orelse
|
|
return bun.debugAssert(false);
|
|
entry.ref_count += 1;
|
|
|
|
var new_weak_ref_count: u32 = 1;
|
|
|
|
for (0..store.weak_refs.count) |i| {
|
|
const ref = store.weak_refs.peekItem(i);
|
|
if (ref.key() == key) {
|
|
new_weak_ref_count += ref.count;
|
|
store.weak_refs.orderedRemoveItem(i);
|
|
break;
|
|
}
|
|
} else {
|
|
// If full, one must be expired to make room.
|
|
if (store.weak_refs.count >= weak_ref_entry_max) {
|
|
const first = store.weak_refs.readItem().?;
|
|
store.unrefCount(first.key(), first.count);
|
|
if (store.weak_ref_sweep_timer.state == .ACTIVE and
|
|
store.weak_ref_sweep_timer.next.sec == first.expire)
|
|
store.owner().vm.timer.remove(&store.weak_ref_sweep_timer);
|
|
}
|
|
}
|
|
|
|
const expire = bun.timespec.msFromNow(weak_ref_expiry_seconds * 1000);
|
|
store.weak_refs.writeItem(.init(
|
|
key,
|
|
new_weak_ref_count,
|
|
expire.sec,
|
|
)) catch
|
|
unreachable; // space has been cleared above
|
|
|
|
if (store.weak_ref_sweep_timer.state != .ACTIVE) {
|
|
mapLog("arming weak ref sweep timer", .{});
|
|
store.owner().vm.timer.update(&store.weak_ref_sweep_timer, &expire);
|
|
}
|
|
mapLog("addWeakRef {x}, ref_count: {d}", .{ key.get(), entry.ref_count });
|
|
}
|
|
|
|
/// Returns true if the ref count was incremented (meaning there was a source map to transfer)
|
|
pub fn removeOrUpgradeWeakRef(store: *SourceMapStore, key: Key, mode: enum(u1) {
|
|
/// Remove the weak ref entirely
|
|
remove = 0,
|
|
/// Convert the weak ref into a strong ref
|
|
upgrade = 1,
|
|
}) bool {
|
|
const entry = store.entries.getPtr(key) orelse
|
|
return false;
|
|
for (0..store.weak_refs.count) |i| {
|
|
const ref = store.weak_refs.peekItemMut(i);
|
|
if (ref.key() == key) {
|
|
ref.count -|= 1;
|
|
if (mode == .remove) {
|
|
store.unref(key);
|
|
}
|
|
if (ref.count == 0) {
|
|
store.weak_refs.orderedRemoveItem(i);
|
|
}
|
|
break;
|
|
}
|
|
} else {
|
|
entry.ref_count += @intFromEnum(mode);
|
|
}
|
|
mapLog("maybeUpgradeWeakRef {x}, ref_count: {d}", .{
|
|
key.get(),
|
|
entry.ref_count,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
pub fn locateWeakRef(store: *SourceMapStore, key: Key) ?struct { index: usize, ref: WeakRef } {
|
|
for (0..store.weak_refs.count) |i| {
|
|
const ref = store.weak_refs.peekItem(i);
|
|
if (ref.key() == key) return .{ .index = i, .ref = ref };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
pub fn sweepWeakRefs(timer: *EventLoopTimer, now_ts: *const bun.timespec) EventLoopTimer.Arm {
|
|
mapLog("sweepWeakRefs", .{});
|
|
const store: *SourceMapStore = @fieldParentPtr("weak_ref_sweep_timer", timer);
|
|
assert(store.owner().magic == .valid);
|
|
|
|
const now: u64 = @max(now_ts.sec, 0);
|
|
|
|
defer store.owner().emitMemoryVisualizerMessageIfNeeded();
|
|
|
|
while (store.weak_refs.readItem()) |item| {
|
|
if (item.expire <= now) {
|
|
store.unrefCount(item.key(), item.count);
|
|
} else {
|
|
store.weak_refs.unget(&.{item}) catch
|
|
unreachable; // there is enough space since the last item was just removed.
|
|
store.weak_ref_sweep_timer.state = .FIRED;
|
|
store.owner().vm.timer.update(
|
|
&store.weak_ref_sweep_timer,
|
|
&.{ .sec = item.expire + 1, .nsec = 0 },
|
|
);
|
|
return .disarm;
|
|
}
|
|
}
|
|
|
|
store.weak_ref_sweep_timer.state = .CANCELLED;
|
|
|
|
return .disarm;
|
|
}
|
|
|
|
pub const GetResult = struct {
|
|
index: bun.GenericIndex(u32, Entry),
|
|
mappings: SourceMap.MappingsData,
|
|
file_paths: []const []const u8,
|
|
entry_files: *const bun.MultiArrayList(PackedMap.RefOrEmpty),
|
|
|
|
pub fn deinit(self: *@This(), allocator: Allocator) void {
|
|
self.mappings.deinit(allocator);
|
|
// file paths and source contents are borrowed
|
|
}
|
|
};
|
|
|
|
/// This is used in exactly one place: remapping errors.
|
|
/// In that function, an arena allows reusing memory between different source maps
|
|
pub fn getParsedSourceMap(store: *SourceMapStore, script_id: Key, arena: Allocator, gpa: Allocator) ?GetResult {
|
|
const index = store.entries.getIndex(script_id) orelse
|
|
return null; // source map was collected.
|
|
const entry = &store.entries.values()[index];
|
|
|
|
const script_id_decoded: SourceMapStore.SourceId = @bitCast(script_id.get());
|
|
const vlq_bytes = entry.renderMappings(script_id_decoded.kind, arena, arena) catch bun.outOfMemory();
|
|
|
|
switch (SourceMap.Mapping.parse(
|
|
gpa,
|
|
vlq_bytes,
|
|
null,
|
|
@intCast(entry.paths.len),
|
|
0, // unused
|
|
.{},
|
|
)) {
|
|
.fail => |fail| {
|
|
Output.debugWarn("Failed to re-parse source map: {s}", .{fail.msg});
|
|
return null;
|
|
},
|
|
.success => |psm| {
|
|
return .{
|
|
.index = .init(@intCast(index)),
|
|
.mappings = psm.mappings,
|
|
.file_paths = entry.paths,
|
|
.entry_files = &entry.files,
|
|
};
|
|
},
|
|
}
|
|
}
|
|
|
|
const bun = @import("bun");
|
|
const Environment = bun.Environment;
|
|
const Output = bun.Output;
|
|
const SourceMap = bun.sourcemap;
|
|
const StringJoiner = bun.StringJoiner;
|
|
const assert = bun.assert;
|
|
const bake = bun.bake;
|
|
const VoidFieldTypes = bun.meta.VoidFieldTypes;
|
|
const EventLoopTimer = bun.api.Timer.EventLoopTimer;
|
|
|
|
const DevServer = bun.bake.DevServer;
|
|
const ChunkKind = DevServer.ChunkKind;
|
|
const PackedMap = DevServer.PackedMap;
|
|
const dumpBundle = DevServer.dumpBundle;
|
|
const mapLog = DevServer.mapLog;
|
|
|
|
const std = @import("std");
|
|
const AutoArrayHashMapUnmanaged = std.AutoArrayHashMapUnmanaged;
|
|
const Allocator = std.mem.Allocator;
|