Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
32 changes: 32 additions & 0 deletions docs/runtime/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,38 @@ Bun.build({

This plugin transforms all imports of the form `import env from "env"` into a JavaScript module that exports the current environment variables.

#### Source maps

A plugin that you register with `Bun.plugin()` can end `contents` with an inline source map of the file it rewrote: a `//# sourceMappingURL=` comment on a line of its own, with the map as a base64 `data:application/json` URL. Bun then reports stack traces and `bun test --coverage` lines at their positions in the file instead of their positions in `contents`.

```ts plugin.ts icon="/icons/typescript.svg"
import MagicString from "magic-string";
import { basename } from "node:path";

Bun.plugin({
name: "banner",
setup(build) {
build.onLoad({ filter: /\.ts$/ }, async ({ path }) => {
const code = new MagicString(await Bun.file(path).text());
code.prepend("/* generated */\n");
const map = code.generateMap({ source: basename(path), hires: true });
return {
contents: `${code}\n//# sourceMappingURL=${map.toUrl()}`,
loader: "ts",
};
});
},
});
```

Load `plugin.ts` with [`preload`](/runtime/bunfig#preload), so that it runs before the files it rewrites.

The map's `sources` must have exactly one entry, and it must resolve to the file. Bun resolves a relative entry from the file's directory, and does not read `sourceRoot`. Bun ignores any other map.

Code that the map does not cover has no position in the file. Coverage leaves it out, and a stack frame in it keeps its position in `contents`.

`Bun.build()` does not read the comment. With `--inspect`, the debugger shows `contents`, so all positions stay in `contents`.

#### `.defer()`

The `onLoad` callback receives a `defer` function, which returns a `Promise` that resolves once Bun has loaded all _other_ modules. Await it when a module's contents depend on other modules.
Expand Down
9 changes: 9 additions & 0 deletions packages/bun-types/bun.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6387,6 +6387,15 @@ declare module "bun" {
interface OnLoadResultSourceCode {
/**
* The source code of the module
*
* In a runtime plugin, `contents` can end with a
* `//# sourceMappingURL=data:application/json;base64,...` comment. If the
* only entry in that map's `sources` resolves to the file being loaded,
* stack traces and `bun test --coverage` report positions in the file
* instead of positions in `contents`. Code that the map does not cover
* keeps its position in `contents` and is left out of coverage.
*
* The comment has no effect in `Bun.build()` or with `--inspect`.
*/
contents: string | ArrayBufferView | ArrayBuffer | SharedArrayBuffer;
/**
Expand Down
7 changes: 7 additions & 0 deletions src/js_printer/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1267,6 +1267,8 @@ pub fn write_json_string<W: Write + ?Sized, const ENCODING: Encoding>(
pub struct SourceMapHandler<'a> {
pub(crate) ctx: NonNull<()>,
pub(crate) callback: fn(*mut (), SourceMap::Chunk, &bun_ast::Source) -> crate::Result<()>,
/// The map the source came with.
pub input_source_map: Option<&'a SourceMap::ParsedSourceMap>,
_marker: core::marker::PhantomData<&'a mut ()>,
}

Expand Down Expand Up @@ -1306,6 +1308,7 @@ impl<'a> SourceMapHandler<'a> {
// Type-erased to `*mut ()` and cast back to `*mut T` inside the thunk before dereference.
ctx: NonNull::from(ctx).cast::<()>(),
callback: thunk::<T>,
input_source_map: None,
_marker: core::marker::PhantomData,
}
}
Expand Down Expand Up @@ -7673,6 +7676,10 @@ pub(crate) fn get_source_map_builder<'a, const IS_BUN_PLATFORM: bool>(
},
None => LineOffsetTables::None,
},
input_source_map: opts
.source_map_handler
.as_ref()
.and_then(|handler| handler.input_source_map),
..Default::default()
};
// Pre-size the VLQ mappings buffer. With `--minify` we emit roughly one
Expand Down
10 changes: 9 additions & 1 deletion src/jsc/VirtualMachine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1722,6 +1722,7 @@ impl VirtualMachine {
SourceMapHandlerGetter {
vm: self,
printer,
input_source_map: None,
_marker: core::marker::PhantomData,
}
}
Expand Down Expand Up @@ -3824,6 +3825,8 @@ pub fn process_fetch_log(
pub struct SourceMapHandlerGetter<'a> {
vm: *mut VirtualMachine,
printer: *mut bun_js_printer::BufferPrinter,
/// The map the source came with.
pub input_source_map: Option<&'a bun_sourcemap::ParsedSourceMap>,
_marker: core::marker::PhantomData<&'a mut ()>,
}

Expand All @@ -3843,6 +3846,7 @@ impl<'a> SourceMapHandlerGetter<'a> {
Self {
vm,
printer,
input_source_map: None,
_marker: core::marker::PhantomData,
}
}
Expand Down Expand Up @@ -3901,11 +3905,15 @@ impl<'a> SourceMapHandlerGetter<'a> {
Some(d) if d.mode != crate::debugger::Mode::Connect
);
if !wants_inline_source_map {
let input_source_map = self.input_source_map;
// `source_mappings` is a value field on the VM, exclusively
// borrowed for the returned handler's lifetime (bounded by
// `&mut self`).
return bun_js_printer::SourceMapHandler::for_(self.vm_source_mappings_mut());
let mut handler = bun_js_printer::SourceMapHandler::for_(self.vm_source_mappings_mut());
handler.input_source_map = input_source_map;
return handler;
}
// The inspector shows the source, so the mappings stay positions in it.
bun_js_printer::SourceMapHandler::for_(self)
}
}
Expand Down
4 changes: 4 additions & 0 deletions src/runtime/jsc_hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2491,6 +2491,7 @@ fn transpile_source_code_of_any_provenance(
// ── Node-fallback virtual source ────────────────────────────────
let fallback_source: bun_ast::Source;
let mut virtual_source = args.virtual_source;
let input_source_map = virtual_source.and_then(bun_sourcemap::parse_input_source_map);
if is_node_override {
if let Some(code) = node_fallbacks::contents_from_path(specifier) {
{
Expand Down Expand Up @@ -2633,7 +2634,9 @@ fn transpile_source_code_of_any_provenance(
keep_json_and_toml_as_one_statement: true,
allow_bytecode_cache: true,
set_breakpoint_on_first_line,
// The cache's key is the text. With an input source map the path matters too.
runtime_transpiler_cache: if !disable_transpilying
&& input_source_map.is_none()
&& !<RuntimeTranspilerCache as bun_bundler::RuntimeTranspilerCacheExt>::disabled()
{
Some(&mut cache)
Expand Down Expand Up @@ -3102,6 +3105,7 @@ fn transpile_source_code_of_any_provenance(
// is bounded by the block.
let mut mapper =
unsafe { (*jsc_vm).source_map_handler((*extra).source_code_printer) };
mapper.input_source_map = input_source_map.as_deref();
// SAFETY: per fn contract — `jsc_vm` / `extra.source_code_printer`
// are live; the printer borrow is scoped to this call.
let print_result = unsafe {
Expand Down
30 changes: 27 additions & 3 deletions src/sourcemap/Chunk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ use bun_paths::fs::FileSystem;
use bun_ptr::RawSlice;

use crate::{
InternalSourceMap, LineOffsetTable, SourceMapState, append_mapping_to_buffer,
internal_source_map, line_offset_table, line_offset_table::LineOffsetTableColumns as _,
InternalSourceMap, LineOffsetTable, Ordinal, ParsedSourceMap, SourceMapState,
append_mapping_to_buffer, internal_source_map, line_offset_table,
line_offset_table::LineOffsetTableColumns as _,
};

#[derive(Clone)]
Expand Down Expand Up @@ -350,6 +351,9 @@ impl LineOffsetTables<'_> {
}
}

/// The `source_index` of a mapping that [`NewBuilder::input_source_map`] does not cover.
pub const NOT_IN_INPUT_SOURCE_MAP: i32 = 1;

pub struct NewBuilder<'a, T: SourceMapFormatCtx> {
pub source_map: SourceMapFormat<T>,
pub line_offset_tables: LineOffsetTables<'a>,
Expand Down Expand Up @@ -382,6 +386,9 @@ pub struct NewBuilder<'a, T: SourceMapFormatCtx> {

/// When generating sourcemappings for bun, we store a count of how many mappings there were
pub prepend_count: bool,

/// The map the source came with. Each mapping goes through it, or gets [`NOT_IN_INPUT_SOURCE_MAP`].
pub input_source_map: Option<&'a ParsedSourceMap>,
}

impl<T: SourceMapFormatCtx + Default> Default for NewBuilder<'_, T> {
Expand All @@ -401,6 +408,7 @@ impl<T: SourceMapFormatCtx + Default> Default for NewBuilder<'_, T> {
line_starts_with_mapping: false,
cover_lines_without_mappings: false,
prepend_count: false,
input_source_map: None,
}
}
}
Expand Down Expand Up @@ -603,7 +611,23 @@ impl NewBuilder<'_, VLQSourceMap> {
}

#[inline(always)]
pub(crate) fn append_mapping(&mut self, current_state: SourceMapState) {
pub(crate) fn append_mapping(&mut self, mut current_state: SourceMapState) {
if let Some(input) = self.input_source_map {
if let Some(mapping) = input.find_mapping(
Ordinal::from_zero_based(current_state.original_line),
Ordinal::from_zero_based(current_state.original_column),
) {
current_state.source_index = 0;
current_state.original_line = mapping.original.lines.zero_based();
// The distance from the mapping stays, so a map with one mapping per line keeps the columns.
current_state.original_column =
mapping.original.columns.zero_based().saturating_add(
current_state.original_column - mapping.generated.columns.zero_based(),
);
} else {
current_state.source_index = NOT_IN_INPUT_SOURCE_MAP;
}
Comment on lines +616 to +629

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) Plugin users whose compiler marks generated code with 1-field segments (Babel, anything built on source-map/gen-mapping) get that code reported at a made-up position in their file instead of left out. The map parser drops 1-field segments (src/sourcemap/Mapping.rs:556-562), so find_mapping at src/sourcemap/Chunk.rs:619 returns the preceding original segment for generated text that follows it on the same line, and the projection at Chunk.rs:627 attributes it to the file with source index 0. Fix: treat a 1-field segment as ending the previous segment's range, e.g. keep it in the list with a no-source marker that find_mapping reports as uncovered, so the NOT_IN_INPUT_SOURCE_MAP branch fires for it and coverage skips it.

Why this was flagged

A runtime onLoad plugin returns Babel or source-map-generated output ending in an inline map whose single source is the file; such maps mark synthesized code with 1-field segments, which per the source map spec mean "no original position" and end the previous segment's range. Mapping::parse at src/sourcemap/Mapping.rs:556-562 discards every 1-field segment. append_mapping at src/sourcemap/Chunk.rs:619-622 then calls find_mapping, which (Mapping.rs:156-171) returns the last mapping on the same generated line with column <= the token's column, i.e. the original segment that the dropped terminator was supposed to close. The token gets source_index 0 at Chunk.rs:623 and a column of orig_col + (col - seg_col) at Chunk.rs:627-628, often past the end of the original line. A throw inside such trailing generated code is reported as file.ts:L:C with a column that does not exist, and bun test --coverage counts that offset against original line L via count_line (src/sourcemap_jsc/CodeCoverage.rs:739) instead of skipping it through is_added_to_the_file (CodeCoverage.rs:722). On the base branch no remapping happened at all.

Verification: The scalar parser discards 1-field segments: src/sourcemap/Mapping.rs:556-573. find_index_from_generated (src/sourcemap/Mapping.rs:150-173) returns index-1 whenever that row is on the same generated line. The new append_mapping (src/sourcemap/Chunk.rs:617-631) then sets source_index = 0 and a fabricated column, and only takes the NOT_IN_INPUT_SOURCE_MAP branch when find_mapping returns None.

}
self.append_mapping_without_remapping(current_state);
Comment thread
claude[bot] marked this conversation as resolved.
}

Expand Down
13 changes: 13 additions & 0 deletions src/sourcemap/ParsedSourceMap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,19 @@ impl ParsedSourceMap {
!self.external_source_names.is_empty()
}

pub fn only_source_is(&self, path: &[u8]) -> bool {
use bun_paths::{platform::Auto, resolve_path};

let [name] = self.external_source_names.as_slice() else {
return false;
};
bun_paths::is_absolute(path)
&& resolve_path::join_abs_string_checked::<Auto>(
resolve_path::dirname::<Auto>(path),
&[name],
) == Some(path)
}

pub fn find_mapping(&self, line: Ordinal, column: Ordinal) -> Option<Mapping> {
if let Some(ism) = &self.internal {
return ism.find(line, column);
Expand Down
42 changes: 29 additions & 13 deletions src/sourcemap/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -841,7 +841,9 @@ pub(crate) fn parse_url(
else {
break 'try_data_url;
};
if &after[..comma] != b"base64" {
// Babel and magic-string write `charset=utf-8;base64`.
let parameters = &after[..comma];
if parameters != b"base64" && !parameters.ends_with(b";base64") {
break 'try_data_url;
}
let base64_data = &after[comma + 1..];
Expand All @@ -866,6 +868,22 @@ pub(crate) fn parse_url(
parse_json(json_bytes, hint)
}

/// The inline source map of what an `onLoad` plugin returned for a file, if it is a map of that file.
pub fn parse_input_source_map(source: &bun_ast::Source) -> Option<std::sync::Arc<ParsedSourceMap>> {
let url = find_source_mapping_url_u8(source.contents())?;
let arena = bun_alloc::Arena::new();
let map = parse_url(&arena, url.slice(), ParseUrlResultHint::MappingsOnly)
.ok()?
.map?;
if !map.only_source_is(source.path.text) {
return None;
}
// A map that the file ends with too says how the file was made, and the plugin passed it on.
let is_from_the_file = bun_sys::File::read_from(bun_sys::Fd::cwd(), source.path.text)
.is_ok_and(|file| find_source_mapping_url_u8(&file).is_some_and(|own| *own == *url));
(!is_from_the_file).then_some(map)
}

/// Parses a JSON source-map
///
/// `source` must be in UTF-8 and can be freed after this call.
Expand Down Expand Up @@ -907,15 +925,12 @@ pub(crate) fn parse_json(source: &[u8], hint: ParseUrlResultHint) -> crate::Resu
return Err(crate::Error::InvalidSourceMap);
};

let sources_content = match json
.get(b"sourcesContent")
.ok_or(crate::Error::InvalidSourceMap)?
.data
{
bun_ast::ExprData::EArrayJSON(arr) => arr,
_ => return Err(crate::Error::InvalidSourceMap),
let sources_content = match json.get(b"sourcesContent").map(|contents| contents.data) {
Some(bun_ast::ExprData::EArrayJSON(arr)) => Some(arr),
Some(bun_ast::ExprData::ENull(_)) | None => None,
Some(_) => return Err(crate::Error::InvalidSourceMap),
Comment thread
dylan-conway marked this conversation as resolved.
};
let sources_content = sources_content.get();
let sources_content = sources_content.as_ref().map(|arr| arr.get().items());

let sources_paths = match json
.get(b"sources")
Expand All @@ -927,15 +942,16 @@ pub(crate) fn parse_json(source: &[u8], hint: ParseUrlResultHint) -> crate::Resu
};
let sources_paths = sources_paths.get();

if sources_content.items().len() != sources_paths.items().len() {
if sources_content.is_some_and(|contents| contents.len() != sources_paths.items().len()) {
return Err(crate::Error::InvalidSourceMap);
}
let sources_content = sources_content.unwrap_or_default();

let source_only = matches!(hint, ParseUrlResultHint::SourceOnly(_));

// `Vec<Box<[u8]>>` drops automatically on error.
let source_paths_slice: Option<Vec<Box<[u8]>>> = if !source_only {
let mut v: Vec<Box<[u8]>> = Vec::with_capacity(sources_content.items().len());
let mut v: Vec<Box<[u8]>> = Vec::with_capacity(sources_paths.items().len());
for item in sources_paths.items() {
let Some(s) = item.as_str() else {
return Err(crate::Error::InvalidSourceMap);
Expand Down Expand Up @@ -1028,9 +1044,9 @@ pub(crate) fn parse_json(source: &[u8], hint: ParseUrlResultHint) -> crate::Resu
let content_slice: Option<Box<[u8]>> = match source_index {
Some(idx)
if !matches!(hint, ParseUrlResultHint::MappingsOnly)
&& (idx as usize) < sources_content.items().len() =>
&& (idx as usize) < sources_content.len() =>
'content: {
let item = &sources_content.items()[idx as usize];
let item = &sources_content[idx as usize];
let Some(str) = item.as_str() else {
break 'content None;
};
Expand Down
17 changes: 15 additions & 2 deletions src/sourcemap_jsc/CodeCoverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ use bun_collections::VecExt;
use bun_collections::bit_set::DynamicBitSet;
use bun_core::{self, Utf8Bytes};
use bun_jsc::{JSGlobalObject, JSValue, VM, bun_string_jsc};
use bun_sourcemap::{Ordinal, ParsedSourceMap};
use bun_sourcemap::chunk::NOT_IN_INPUT_SOURCE_MAP;
use bun_sourcemap::{Mapping, Ordinal, ParsedSourceMap};

type LinesHits = Vec<u32>;
type Bitset = DynamicBitSet;
Expand Down Expand Up @@ -687,6 +688,8 @@ impl ByteRangeMapping {
.as_mut()
.source_mappings()
.get(source_url)
// The lines in the map of a bundle are lines of other files.
.filter(|map| !map.is_external() || map.only_source_is(source_url))
};

let mut blocks: Vec<CountedRange> =
Expand Down Expand Up @@ -718,6 +721,9 @@ impl ByteRangeMapping {
if let Some(source_map) = source_map.as_deref() {
let mut previous_original = None;
source_map.for_each_mapping(|mapping| {
if is_added_to_the_file(&mapping) {
return;
}
// The printer repeats a mapping at column 0 of a line that starts without one.
let original = (
mapping.original.lines.zero_based(),
Expand Down Expand Up @@ -752,7 +758,9 @@ impl ByteRangeMapping {
if let Some(source_map) = source_map.as_deref() {
functions.retain(|function| {
let (line, column) = self.line_and_column_of(function.range.start);
source_map.find_mapping(line, column).is_some()
source_map
.find_mapping(line, column)
.is_some_and(|mapping| !is_added_to_the_file(&mapping))
});
}

Expand Down Expand Up @@ -827,6 +835,11 @@ impl ByteRangeMapping {
}
}

/// Code that a plugin added to the file it rewrote: it has no line in the file.
fn is_added_to_the_file(mapping: &Mapping) -> bool {
mapping.source_index == NOT_IN_INPUT_SOURCE_MAP
}

/// `ByteRangeMapping::line_starts` and `executable_units`. Lines end where
/// `bun_sourcemap::Chunk`'s builder ends a generated line.
fn scan(units: impl ExactSizeIterator<Item = u16>) -> (Vec<u32>, Bitset) {
Expand Down
Loading
Loading