From 7ce37bf7c282f6f5b54fe8bba58415b92c84ad46 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:49:10 +0000 Subject: [PATCH] bake: restore the dev server's visualizer pages, incremental graph feed and .bake-debug dumps The Rust port put these behind a cargo feature that the build never enabled, so they were compiled out and later deleted as dead code. Gate them on feature_flags::BAKE_DEBUGGING_FEATURES like the rest of the debugging features instead: - serve /_bun/incremental_visualizer and /_bun/memory_visualizer (plus the /_bun/iv and /_bun/mv shortcuts) - publish the incremental graph on the "v" topic when a socket subscribes and after every bundle - debug builds dump every bundled module, the latest chunks and their source maps below .bake-debug/ in the cwd again --- src/runtime/bake/DevServer.rs | 276 ++++++++++- .../bake/dev_server/incremental_graph.rs | 65 ++- src/runtime/bake/dev_server/memory_cost.rs | 2 + src/runtime/bake/dev_server/mod.rs | 13 + .../bake/dev_server/source_map_store.rs | 12 +- test/bake/dev/debugging-features.test.ts | 466 ++++++++++++++++++ 6 files changed, 810 insertions(+), 24 deletions(-) create mode 100644 test/bake/dev/debugging-features.test.ts diff --git a/src/runtime/bake/DevServer.rs b/src/runtime/bake/DevServer.rs index e8f6e34786c5..953b85713262 100644 --- a/src/runtime/bake/DevServer.rs +++ b/src/runtime/bake/DevServer.rs @@ -19,7 +19,7 @@ use bun_ast::Log; use bun_bundler::options_impl::TargetExt as _; use bun_collections::{ArrayHashMap, DynamicBitSet, HashMap, HiveArrayFallback, StringHashMap}; use bun_core::{self as str, OwnedString, String as BunString, ZStr, strings}; -use bun_core::{Environment, Output}; +use bun_core::{Environment, Output, feature_flags}; use bun_jsc::StringJsc as _; use bun_jsc::virtual_machine::VirtualMachine; use bun_jsc::{self as jsc, CallFrame, JSGlobalObject, JSValue, JsResult}; @@ -417,6 +417,9 @@ pub struct DevServer { pub(crate) active_websocket_connections: HashMap<*mut HmrSocket, ()>, // Debugging + /// Where `dump_bundle` writes every bundled module and chunk. Only debug + /// builds open it (see `DUMP_SOURCES_DIR`). + pub(crate) dump_dir: Option, /// Reference count to number of active sockets with the incremental_visualizer enabled. pub(crate) emit_incremental_visualizer_events: u32, /// Reference count to number of active sockets with the memory_visualizer enabled. @@ -444,6 +447,12 @@ const ASSET_PREFIX: &str = const_format::concatcp!(INTERNAL_PREFIX, "/asset"); /// /// Example: `/_bun/client/index-00000000f209a20e.js` const CLIENT_PREFIX: &str = const_format::concatcp!(INTERNAL_PREFIX, "/client"); +/// Debugging pages (`feature_flags::BAKE_DEBUGGING_FEATURES`). Each page +/// subscribes to its `HmrTopic` over `/_bun/hmr`; `/_bun/iv` and `/_bun/mv` +/// redirect here. +const INCREMENTAL_VISUALIZER_PATH: &str = + const_format::concatcp!(INTERNAL_PREFIX, "/incremental_visualizer"); +const MEMORY_VISUALIZER_PATH: &str = const_format::concatcp!(INTERNAL_PREFIX, "/memory_visualizer"); #[derive(Default)] pub struct DeferredPromise { @@ -529,6 +538,9 @@ pub(crate) fn init(options: Options) -> JsResult> { w!(graph_safety_lock, ThreadLock::init_unlocked()); w!(framework, options.framework); w!(bundler_options, options.bundler_options); + // Opened below, once the box is fully initialized, so an early return + // in between closes it through `Drop` like every other field. + w!(dump_dir, None); w!(emit_incremental_visualizer_events, 0); w!(emit_memory_visualizer_events, 0); w!( @@ -727,6 +739,8 @@ pub(crate) fn init(options: Options) -> JsResult> { let mut dev: Box = unsafe { dev_uninit.assume_init() }; let dev_ptr: *mut DevServer = &raw mut *dev; + dev.dump_dir = open_dump_dir(); + // Note: the graphs are stored by value; `owner()` is provided on // `IncrementalGraph` via `offset_of!` so the parent-pointer invariant is // structural. Retain the field touches so the addresses are stable for @@ -1060,6 +1074,7 @@ impl Drop for DevServer { next_bundle: _, deferred_request_pool: _, active_websocket_connections: _, + dump_dir: _, emit_incremental_visualizer_events: _, emit_memory_visualizer_events: _, memory_visualizer_timer: _, @@ -1337,6 +1352,29 @@ impl DevServer { hmr_socket_behavior::(), ); + if feature_flags::BAKE_DEBUGGING_FEATURES { + route!( + get, + INCREMENTAL_VISUALIZER_PATH.as_bytes(), + DevHandlerId::IncrementalVisualizer + ); + route!( + get, + MEMORY_VISUALIZER_PATH.as_bytes(), + DevHandlerId::MemoryVisualizer + ); + route!( + get, + const_format::concatcp!(INTERNAL_PREFIX, "/iv").as_bytes(), + DevHandlerId::IncrementalVisualizerShortcut + ); + route!( + get, + const_format::concatcp!(INTERNAL_PREFIX, "/mv").as_bytes(), + DevHandlerId::MemoryVisualizerShortcut + ); + } + // Only attach a catch-all handler if the framework has filesystem // router types. Otherwise, this can just be Bun.serve's default handler. if !self.framework.file_system_router_types.is_empty() { @@ -1360,6 +1398,10 @@ pub(super) enum DevHandlerId { UnrefSourceMap, NotFound, Request, + IncrementalVisualizer, + MemoryVisualizer, + IncrementalVisualizerShortcut, + MemoryVisualizerShortcut, } /// DNS-rebinding guard for `/_bun/...` internal routes and the Chrome @@ -1544,9 +1586,37 @@ extern "C" fn dev_route_tramp( // trampoline folds what bundling for it left pending. crate::dispatch::fold(on_request(unsafe { &mut *dev }, unsafe { &mut *req }, resp)); } + DevHandlerId::IncrementalVisualizer => send_html_page( + resp, + bun_core::runtime_embed_file!(SrcEager, "runtime/bake/incremental_visualizer.html"), + ), + DevHandlerId::MemoryVisualizer => send_html_page( + resp, + bun_core::runtime_embed_file!(SrcEager, "runtime/bake/memory_visualizer.html"), + ), + DevHandlerId::IncrementalVisualizerShortcut => { + send_redirect(resp, INCREMENTAL_VISUALIZER_PATH) + } + DevHandlerId::MemoryVisualizerShortcut => send_redirect(resp, MEMORY_VISUALIZER_PATH), } } +fn send_html_page(resp: AnyResponse, html: &'static str) { + resp.corked(move || { + resp.write_status(b"200 OK"); + resp.write_header(b"Content-Type", &MimeType::HTML.value); + resp.end(html.as_bytes(), false); + }); +} + +fn send_redirect(resp: AnyResponse, location: &'static str) { + resp.corked(move || { + resp.write_status(b"302 Found"); + resp.write_header(b"Location", location.as_bytes()); + resp.end(b"Redirecting...", false); + }); +} + fn on_report_error_request(dev: &mut DevServer, req: &mut Request, resp: AnyResponse) { use bun_uws_sys::thunk::OpaqueHandle as _; match resp { @@ -5440,8 +5510,114 @@ impl DevServer { // body module re-exports it so both modules name the same type. pub(super) use crate::bake::dev_server::ChunkKind; +/// Debug builds write every bundled module and chunk below this directory +/// (relative to the cwd; it is in the repository's `.gitignore`), which is the +/// only way to read what the dev server actually produced, since the chunks +/// otherwise only ever live in memory or in a browser tab. +const DUMP_SOURCES_DIR: Option<&[u8]> = if bun_core::env::IS_DEBUG { + Some(b".bake-debug") +} else { + None +}; + +fn open_dump_dir() -> Option { + let dir = DUMP_SOURCES_DIR?; + match sys::Dir::cwd().make_open_path(dir, Default::default()) { + Ok(dump_dir) => Some(dump_dir), + Err(err) => { + bun_core::warn!("Could not open directory for dumping sources: {}", err); + None + } + } +} + +/// Writes `chunk` to `//`. Source maps (`.map`) +/// are written verbatim; anything else gets a comment header saying when and +/// by which Bun it was bundled. `wrap` encloses the chunk in `({ ... });`, +/// which makes a single module (an object property in the HMR format) parse +/// on its own. Failures only produce a warning: the dump is a debugging aid +/// and must never fail the bundle. +pub(super) fn dump_bundle( + dump_dir: &sys::Dir, + graph: bake::Graph, + rel_path: &[u8], + chunk: &[u8], + wrap: bool, +) { + let mut buf = paths::path_buffer_pool::get(); + let name = &paths::resolve_path::join_abs_string_buf::( + b"/", + &mut buf[..], + &[<&'static str>::from(graph).as_bytes(), rel_path], + )[1..]; + + let mut contents: Vec = Vec::with_capacity(chunk.len() + 256); + if !strings::has_suffix_comptime(rel_path, b".map") { + let _ = writeln!( + contents, + "// {} bundled for {}\n// Bundled at {}, Bun {}", + bun_core::fmt::quote(rel_path), + <&'static str>::from(graph), + bun_core::time::nano_timestamp(), + bun_core::Global::package_json_version_with_canary, + ); + } + if wrap { + contents.extend_from_slice(b"({\n"); + } + contents.extend_from_slice(chunk); + if wrap { + contents.extend_from_slice(b"});\n"); + } + + let written = dump_dir + .make_open_path( + paths::resolve_path::dirname::(name), + Default::default(), + ) + .and_then(|dir| sys::File::create(&dir, paths::basename(name), true)) + .and_then(|file| file.write_all(&contents)); + if let Err(err) = written { + bun_core::warn!("Could not dump bundle: {}", err); + } +} + +/// `dump_bundle` for one module received from the bundler, named after its +/// path relative to the project `root`. Files outside of the root keep their +/// relative path, with every `..` segment turned into the directory `_.._` so +/// the dump stays inside `dump_dir`. +pub(super) fn dump_bundle_for_chunk( + dump_dir: &sys::Dir, + root: &[u8], + graph: bake::Graph, + key: &[u8], + code: &[u8], +) { + let mut rel_path_buf = paths::path_buffer_pool::get(); + let rel_path = paths::resolve_path::relative_buf_z(&mut rel_path_buf[..], root, key).as_bytes(); + let rel_path_escaped = strings::replace_owned( + rel_path, + const_format::concatcp!("..", paths::SEP_STR).as_bytes(), + const_format::concatcp!("_.._", paths::SEP_STR).as_bytes(), + ); + dump_bundle(dump_dir, graph, &rel_path_escaped, code, true); +} + impl DevServer { - pub fn emit_visualizer_message_if_needed(&mut self) {} + /// Called after every bundle. Only does work while a page served from + /// `/_bun/incremental_visualizer` or `/_bun/memory_visualizer` is + /// subscribed (`HmrSocket` keeps the two counters). + pub fn emit_visualizer_message_if_needed(&mut self) { + if !feature_flags::BAKE_DEBUGGING_FEATURES { + return; + } + if self.emit_incremental_visualizer_events > 0 { + let mut payload: Vec = Vec::with_capacity(65536); + self.write_visualizer_message(&mut payload); + self.publish(HmrTopic::IncrementalVisualizer, &payload, Opcode::BINARY); + } + self.emit_memory_visualizer_message_if_needed(); + } #[inline] fn timer_heap(&self) -> &mut crate::timer::All { @@ -5516,6 +5692,102 @@ impl DevServer { } Ok(()) } + + /// The `MessageId::Visualizer` format decoded by `incremental_visualizer.html`. + /// Also usable from the crash handler (`BUN_DUMP_STATE_ON_CRASH`), where the graphs + /// may be mid-mutation: counts are written after their entries, and the path buffer + /// pool (a `RefCell`) is not used. + fn write_visualizer_message(&self, payload: &mut Vec) { + payload.push(MessageId::Visualizer.char()); + let mut buf = Box::new(PathBuffer::ZEROED); + self.write_visualizer_files(&self.client_graph, payload, &mut buf); + self.write_visualizer_files(&self.server_graph, payload, &mut buf); + write_visualizer_edges(&self.client_graph, payload); + write_visualizer_edges(&self.server_graph, payload); + } + + /// `u32` count, then per file: `u32` path length (0 = deleted file, nothing + /// follows), the path, and six `u8` flags: stale, RSC, SSR, route, + /// framework file, boundary (client side: HMR root). + fn write_visualizer_files( + &self, + graph: &IncrementalGraph, + payload: &mut Vec, + buf: &mut PathBuffer, + ) { + let count_at = reserve_visualizer_count(payload); + let mut count = 0u32; + let keys = graph.bundled_files.keys(); + for (i, (key, file)) in keys.iter().zip(graph.bundled_files.values()).enumerate() { + count += 1; + if key.is_empty() { + payload.extend_from_slice(&0u32.to_le_bytes()); + continue; + } + let path = self.relative_path(buf, key); + payload.extend_from_slice(&u32::try_from(path.len()).expect("int cast").to_le_bytes()); + payload.extend_from_slice(path); + let stale = graph.stale_files.is_set_allow_out_of_bound(i, true) || file.failed; + let flags: [bool; 6] = match SIDE { + bake::Side::Client => [ + stale, + false, + false, + file.html_route_bundle_index.is_some(), + file.is_special_framework_file, + file.is_hmr_root, + ], + bake::Side::Server => [ + stale, + file.is_rsc, + file.is_ssr, + file.is_route, + false, + file.is_client_component_boundary, + ], + }; + payload.extend_from_slice(&flags.map(u8::from)); + } + set_visualizer_count(payload, count_at, count); + } +} + +/// `u32` count, then per live edge: `u32` importer index, `u32` imported index. +fn write_visualizer_edges( + graph: &IncrementalGraph, + payload: &mut Vec, +) { + let mut freed = vec![false; graph.edges.len()]; + for free in &graph.edges_free_list { + if let Some(slot) = freed.get_mut(free.get() as usize) { + *slot = true; + } + } + let count_at = reserve_visualizer_count(payload); + let mut count = 0u32; + for (edge, _) in graph + .edges + .iter() + .zip(freed) + .filter(|(_, is_freed)| !is_freed) + { + count += 1; + payload.extend_from_slice(&edge.dependency.get().to_le_bytes()); + payload.extend_from_slice(&edge.imported.get().to_le_bytes()); + } + set_visualizer_count(payload, count_at, count); +} + +/// Leaves room for a list's `u32` count, filled in by `set_visualizer_count` +/// once the number of entries actually written is known. +fn reserve_visualizer_count(payload: &mut Vec) -> usize { + let at = payload.len(); + payload.extend_from_slice(&0u32.to_le_bytes()); + at +} + +fn set_visualizer_count(payload: &mut [u8], at: usize, count: u32) { + payload[at..at + 4].copy_from_slice(&count.to_le_bytes()); } // Note: MessageId/IncomingMessageId/ConsoleLogKind/HmrTopic are defined diff --git a/src/runtime/bake/dev_server/incremental_graph.rs b/src/runtime/bake/dev_server/incremental_graph.rs index 3522f6447791..0a16799693ba 100644 --- a/src/runtime/bake/dev_server/incremental_graph.rs +++ b/src/runtime/bake/dev_server/incremental_graph.rs @@ -19,7 +19,9 @@ use super::{ SerializedFailure, TraceImportGoal, packed_map, route_bundle, serialized_failure, source_map_store, }; -use crate::bake::dev_server_body::{CachedFileIndex, HotUpdateContext}; +use crate::bake::dev_server_body::{ + CachedFileIndex, HotUpdateContext, dump_bundle, dump_bundle_for_chunk, +}; use crate::bake::{self, Side}; /// `bun.GenericIndex(u30, File)` — file index into `bundled_files`. @@ -547,24 +549,27 @@ impl IncrementalGraph { let path = &ctx.sources[index.get() as usize].path; let key = path.key_for_incremental_graph(); + let graph = match SIDE { + Side::Client => bake::Graph::Client, + Side::Server if is_ssr_graph => bake::Graph::Ssr, + Side::Server => bake::Graph::Server, + }; - if cfg!(debug_assertions) { - if let ReceiveChunkContent::Js { code, .. } = &content { - if strings::is_all_whitespace(code) { - bun_core::Output::panic(format_args!( - "Empty chunk is impossible: {} {}", - bstr::BStr::new(key), - match SIDE { - Side::Client => "client", - Side::Server => - if is_ssr_graph { - "ssr" - } else { - "server" - }, - }, - )); - } + if let ReceiveChunkContent::Js { code, .. } = &content { + if cfg!(debug_assertions) && strings::is_all_whitespace(code) { + bun_core::Output::panic(format_args!( + "Empty chunk is impossible: {} {}", + bstr::BStr::new(key), + <&'static str>::from(graph), + )); + } + + // SAFETY: `dump_dir` and `root` are sibling fields of this graph + // (see `owner()`); neither is borrowed anywhere else during the call. + if let Some(dump_dir) = unsafe { (*dev).dump_dir.as_ref() } { + // SAFETY: as above. + let root: &[u8] = unsafe { &(*dev).root }; + dump_bundle_for_chunk(dump_dir, root, graph, key, code); } } @@ -1789,7 +1794,16 @@ impl IncrementalGraph { } list.extend_from_slice(&end_list); - let _ = start; + // SAFETY: `dump_dir` is a sibling field of this graph (see `owner()`). + if let Some(dump_dir) = unsafe { (*dev).dump_dir.as_ref() } { + dump_bundle( + dump_dir, + bake::Graph::Client, + kind.dump_file_name(false), + &list[start..], + false, + ); + } Ok(()) } @@ -1822,7 +1836,18 @@ impl IncrementalGraph { } list.extend_from_slice(end); - let _ = start; + // SAFETY: see `owner()`. + let dev = unsafe { self.owner() }; + // SAFETY: `dump_dir` is a sibling field of this graph (see `owner()`). + if let Some(dump_dir) = unsafe { (*dev).dump_dir.as_ref() } { + dump_bundle( + dump_dir, + bake::Graph::Server, + options.kind.dump_file_name(false), + &list[start..], + false, + ); + } Ok(()) } diff --git a/src/runtime/bake/dev_server/memory_cost.rs b/src/runtime/bake/dev_server/memory_cost.rs index 766c627921cd..88ea2aa208b9 100644 --- a/src/runtime/bake/dev_server/memory_cost.rs +++ b/src/runtime/bake/dev_server/memory_cost.rs @@ -81,6 +81,7 @@ pub(crate) fn memory_cost_detailed(dev: &DevServer) -> MemoryCost { next_bundle: _, deferred_request_pool: _, active_websocket_connections: _, + dump_dir: _, emit_incremental_visualizer_events: _, emit_memory_visualizer_events: _, memory_visualizer_timer: _, @@ -96,6 +97,7 @@ pub(crate) fn memory_cost_detailed(dev: &DevServer) -> MemoryCost { // .configuration_hash_key // .inspector_server_id // .deferred_request_pool + // .dump_dir // .emit_incremental_visualizer_events // .emit_memory_visualizer_events // .frontend_only diff --git a/src/runtime/bake/dev_server/mod.rs b/src/runtime/bake/dev_server/mod.rs index 11a81c24178c..838c30aa9e90 100644 --- a/src/runtime/bake/dev_server/mod.rs +++ b/src/runtime/bake/dev_server/mod.rs @@ -62,6 +62,19 @@ pub enum ChunkKind { HmrChunk = 1, } +impl ChunkKind { + /// Name under which `dev_server_body::dump_bundle` writes the most recent + /// chunk of this kind, or the source map served for it. + pub(crate) fn dump_file_name(self, source_map: bool) -> &'static [u8] { + match (self, source_map) { + (ChunkKind::InitialResponse, false) => b"latest_chunk.js", + (ChunkKind::InitialResponse, true) => b"latest_chunk.js.map", + (ChunkKind::HmrChunk, false) => b"latest_hmr.js", + (ChunkKind::HmrChunk, true) => b"latest_hmr.js.map", + } + } +} + #[derive(Copy, Clone, Eq, PartialEq)] pub enum TraceImportGoal { FindCss, diff --git a/src/runtime/bake/dev_server/source_map_store.rs b/src/runtime/bake/dev_server/source_map_store.rs index c20acff84659..15ce4a9f444e 100644 --- a/src/runtime/bake/dev_server/source_map_store.rs +++ b/src/runtime/bake/dev_server/source_map_store.rs @@ -11,7 +11,7 @@ use bun_core::string_joiner::StringJoiner; use bun_core::{Timespec, TimespecMockMode}; use bun_sourcemap::{self as source_map, SourceMapState}; -use crate::bake::dev_server_body::map_log; +use crate::bake::dev_server_body::{dump_bundle, map_log}; use crate::bake::{self, Side}; use crate::timer::EventLoopTimerState; @@ -218,7 +218,15 @@ impl Entry { let json_bytes = j.done_with_end(b"\"}")?.into_vec(); // errdefer @compileError("last try should be the final alloc") — no further fallible ops below. - let _ = dev; + if let Some(dump_dir) = &dev.dump_dir { + dump_bundle( + dump_dir, + side.graph(), + kind.dump_file_name(true), + &json_bytes, + false, + ); + } Ok(json_bytes) } diff --git a/test/bake/dev/debugging-features.test.ts b/test/bake/dev/debugging-features.test.ts new file mode 100644 index 000000000000..c1fb2b333e4b --- /dev/null +++ b/test/bake/dev/debugging-features.test.ts @@ -0,0 +1,466 @@ +// DevServer debugging features: the visualizer pages under `/_bun/`, the +// incremental graph feed behind them, and the `.bake-debug` source dumps. +// +// The visualizers exist in builds with `BAKE_DEBUGGING_FEATURES` (canary or +// debug). The `.bake-debug` dumps are written by debug builds only. +import { describe, expect } from "bun:test"; +import { isDebug } from "harness"; +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import path from "node:path"; +import { Dev, devTest, emptyHtmlFile, minimalFramework, WAIT_MULTIPLIER } from "../bake-harness"; + +// `feature_flags::BAKE_DEBUGGING_FEATURES` is `IS_CANARY || IS_DEBUG`; canary +// builds are the ones whose version string carries a `-canary.N` tag. +const hasBakeDebuggingFeatures = isDebug || Bun.version_with_sha.includes("-canary."); + +interface VisualizerFile { + name: string; + isStale: boolean; + isServer: boolean; + isSSR: boolean; + isRoute: boolean; + isFramework: boolean; + isBoundary: boolean; +} + +interface VisualizerGraph { + client: (VisualizerFile | { deleted: true })[]; + server: (VisualizerFile | { deleted: true })[]; + /** `[importer, imported]` pairs, by file name. */ + clientEdges: [string, string][]; + serverEdges: [string, string][]; +} + +/** Decodes a `v` (MessageId.visualizer) frame the way `incremental_visualizer.html` does. */ +function decodeVisualizerFrame(buffer: Uint8Array): VisualizerGraph { + const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); + let offset = 1; // MessageId byte + const u32 = () => { + const value = view.getUint32(offset, true); + offset += 4; + return value; + }; + const files = () => { + const count = u32(); + const list: VisualizerGraph["client"] = []; + for (let i = 0; i < count; i++) { + const nameLength = u32(); + if (nameLength === 0) { + list.push({ deleted: true }); + continue; + } + const name = new TextDecoder().decode(buffer.subarray(offset, offset + nameLength)); + offset += nameLength; + list.push({ + name, + isStale: buffer[offset++] === 1, + isServer: buffer[offset++] === 1, + isSSR: buffer[offset++] === 1, + isRoute: buffer[offset++] === 1, + isFramework: buffer[offset++] === 1, + isBoundary: buffer[offset++] === 1, + }); + } + return list; + }; + const fileName = (list: VisualizerGraph["client"], index: number) => { + const file = list[index]; + if (file === undefined || "deleted" in file) + throw new Error(`edge references file #${index}, which does not exist`); + return file.name; + }; + const edges = (list: VisualizerGraph["client"]) => { + const count = u32(); + const pairs: [string, string][] = []; + for (let i = 0; i < count; i++) { + const dependency = u32(); + const imported = u32(); + pairs.push([fileName(list, dependency), fileName(list, imported)]); + } + return pairs; + }; + const client = files(); + const server = files(); + const clientEdges = edges(client); + const serverEdges = edges(server); + if (offset !== buffer.byteLength) { + throw new Error(`visualizer frame has ${buffer.byteLength - offset} trailing bytes`); + } + // Files and edges are emitted in the graph's internal slot order, which the + // visualizer does not care about; sort (by code unit) so the assertions + // below do not depend on it either. + const compare = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0); + const byName = (a: VisualizerGraph["client"][number], b: VisualizerGraph["client"][number]) => + compare("name" in a ? a.name : "", "name" in b ? b.name : ""); + const byPair = (a: [string, string], b: [string, string]) => compare(a.join("\0"), b.join("\0")); + return { + client: client.sort(byName), + server: server.sort(byName), + clientEdges: clientEdges.sort(byPair), + serverEdges: serverEdges.sort(byPair), + }; +} + +function file(name: string, flags: Partial = {}): VisualizerFile { + return { + name, + isStale: false, + isServer: false, + isSSR: false, + isRoute: false, + isFramework: false, + isBoundary: false, + ...flags, + }; +} + +/** + * Opens a second `/_bun/hmr` socket and subscribes it to the incremental + * visualizer topic (`s` = subscribe, `v` = topic), exactly like the page served + * at `/_bun/incremental_visualizer` does. + */ +async function subscribeIncrementalVisualizer(dev: Dev) { + const ws = new WebSocket(dev.baseUrl + "/_bun/hmr"); + ws.binaryType = "arraybuffer"; + // Frames are buffered as they arrive; each `waitForFrame` consumes from `cursor`. + const frames: VisualizerGraph[] = []; + let cursor = 0; + let failure: Error | null = null; + // Called whenever a frame arrives or the socket fails; set by whoever is waiting. + let onProgress = () => {}; + const fail = (reason: string) => { + failure ??= new Error(`${reason} (received ${frames.length} visualizer frames)`); + onProgress(); + }; + ws.onerror = () => fail("hmr socket errored"); + ws.onclose = event => fail(`hmr socket closed with code ${event.code}`); + ws.onmessage = event => { + const data = new Uint8Array(event.data as ArrayBuffer); + if (data[0] !== "v".charCodeAt(0)) return; + try { + frames.push(decodeVisualizerFrame(data)); + } catch (err) { + fail(String(err)); + return; + } + onProgress(); + }; + + const opened = Promise.withResolvers(); + ws.onopen = () => opened.resolve(); + onProgress = () => { + if (failure) opened.reject(failure); + }; + await opened.promise; + + return { + subscribe() { + ws.send("sv"); + }, + /** Resolves with the next frame (after the one the previous wait returned) that satisfies `matches`. */ + waitForFrame(what: string, matches: (graph: VisualizerGraph) => boolean) { + return new Promise((resolve, reject) => { + const deadline = setTimeout(() => fail(`timed out waiting for ${what}`), 10_000 * WAIT_MULTIPLIER); + const settle = () => { + clearTimeout(deadline); + onProgress = () => {}; + }; + onProgress = () => { + if (failure) { + settle(); + reject(failure); + return; + } + while (cursor < frames.length) { + const frame = frames[cursor++]; + if (matches(frame)) { + settle(); + resolve(frame); + return; + } + } + }; + onProgress(); + }); + }, + [Symbol.dispose]() { + ws.onclose = null; + ws.onerror = null; + ws.close(); + }, + }; +} + +describe.skipIf(!hasBakeDebuggingFeatures)("visualizers", () => { + devTest("the visualizer pages are served under /_bun/", { + files: { + // A single HTML file is mounted at `/*`, so the pages must win over the + // app's catch-all (which otherwise serves index.html for these URLs). + "index.html": emptyHtmlFile({}), + }, + async test(dev) { + // The pages are served verbatim from the source tree (embedded at build time in release builds). + const visualizerHtml = path.join(import.meta.dir, "../../../src/runtime/bake"); + for (const name of ["incremental_visualizer", "memory_visualizer"]) { + const response = await dev.fetch(`/_bun/${name}`); + expect({ status: response.status, contentType: response.headers.get("content-type") }).toEqual({ + status: 200, + contentType: "text/html;charset=utf-8", + }); + expect(await response.text()).toBe(readFileSync(path.join(visualizerHtml, `${name}.html`), "utf8")); + } + + for (const [shortcut, target] of [ + ["iv", "incremental_visualizer"], + ["mv", "memory_visualizer"], + ]) { + const response = await dev.fetch(`/_bun/${shortcut}`, { redirect: "manual" }); + expect({ status: response.status, location: response.headers.get("location") }).toEqual({ + status: 302, + location: `/_bun/${target}`, + }); + } + }, + }); + + devTest("subscribing to the incremental visualizer topic streams the incremental graph", { + files: { + "index.html": emptyHtmlFile({ scripts: ["index.ts"] }), + "index.ts": ` + import { value } from "./dep"; + console.log(value); + `, + "dep.ts": `export const value = "dep";`, + "extra.ts": `export const extra = "extra";`, + }, + async test(dev) { + using visualizer = await subscribeIncrementalVisualizer(dev); + visualizer.subscribe(); + + // Subscribing answers with the current graph right away. Nothing has + // been requested yet, so both graphs are empty. + expect(await visualizer.waitForFrame("the frame sent on subscribe", () => true)).toEqual({ + client: [], + server: [], + clientEdges: [], + serverEdges: [], + }); + + // Every finished bundle publishes the graph it produced. + await dev.fetch("/").expect.toInclude(" + graph.client.some(file => "name" in file && file.name === "dep.ts"), + ); + expect(bundled).toEqual({ + client: [file("dep.ts"), file("index.html", { isRoute: true }), file("index.ts")], + server: [], + clientEdges: [ + ["index.html", "index.ts"], + ["index.ts", "dep.ts"], + ], + serverEdges: [], + }); + + await dev.write( + "index.ts", + ` + import { extra } from "./extra"; + console.log(extra); + `, + ); + const updated = await visualizer.waitForFrame("the frame published after the hot update", graph => + graph.client.some(file => "name" in file && file.name === "extra.ts"), + ); + // dep.ts stays in the graph without importers; only its edge is gone. + expect(updated).toEqual({ + client: [file("dep.ts"), file("extra.ts"), file("index.html", { isRoute: true }), file("index.ts")], + server: [], + clientEdges: [ + ["index.html", "index.ts"], + ["index.ts", "extra.ts"], + ], + serverEdges: [], + }); + + await dev.delete("dep.ts"); + const deleted = await visualizer.waitForFrame("the frame published after deleting dep.ts", graph => + graph.client.some(file => "name" in file && file.name === "dep.ts" && file.isStale), + ); + expect(deleted.client).toEqual([ + file("dep.ts", { isStale: true }), + file("extra.ts"), + file("index.html", { isRoute: true }), + file("index.ts"), + ]); + }, + }); + + devTest("the incremental visualizer frame covers the server graph", { + framework: minimalFramework, + files: { + "routes/index.ts": ` + import { marker } from "../components/Comp"; + export default function (req, meta) { + return new Response("page: " + typeof marker); + } + `, + "components/Comp.ts": ` + "use client"; + export const marker = "client"; + `, + }, + async test(dev) { + // Paths are sent relative to the project root, so the framework's entry + // point (which lives in the repository) shows up as a `../` path. + const frameworkEntry = path + .relative(dev.rootDir, realpathSync(minimalFramework.fileSystemRouterTypes[0].serverEntryPoint!)) + .replaceAll(path.sep, "/"); + expect(frameworkEntry.startsWith("../")).toBe(true); + + using visualizer = await subscribeIncrementalVisualizer(dev); + visualizer.subscribe(); + + // Scanning the routes at startup registers the route files and the + // framework entry point as stale server files. + const serverFlags = { isServer: true }; + expect(await visualizer.waitForFrame("the frame sent on subscribe", () => true)).toEqual({ + client: [], + server: [ + file(frameworkEntry, { ...serverFlags, isRoute: true, isStale: true }), + file("routes/index.ts", { ...serverFlags, isRoute: true, isStale: true }), + ], + clientEdges: [], + serverEdges: [], + }); + + // A "use client" component is a boundary in the server graph and an HMR + // root (the same flag byte) in the client graph. Its server-side stub + // imports `registerClientReference` from the framework entry point. + await dev.fetch("/").expect.toInclude("page: "); + const bundled = await visualizer.waitForFrame("the frame published after bundling /", graph => + graph.server.some(file => "name" in file && file.name === "components/Comp.ts"), + ); + expect(bundled).toEqual({ + client: [file("components/Comp.ts", { isBoundary: true })], + server: [ + file(frameworkEntry, { ...serverFlags, isRoute: true }), + file("components/Comp.ts", { ...serverFlags, isBoundary: true }), + file("routes/index.ts", { ...serverFlags, isRoute: true }), + ], + clientEdges: [], + serverEdges: [ + ["components/Comp.ts", frameworkEntry], + ["routes/index.ts", "components/Comp.ts"], + ], + }); + + // Dropping the directive demotes the boundary, which deletes the + // component's file from the client graph. A deleted file keeps its slot + // (indices stay valid) and is sent as an empty name. + await dev.write("components/Comp.ts", `export const marker = "server";`); + const demoted = await visualizer.waitForFrame("the frame published after demoting Comp.ts", graph => + graph.client.some(file => "deleted" in file), + ); + expect(demoted).toEqual({ + client: [{ deleted: true }], + server: [ + file(frameworkEntry, { ...serverFlags, isRoute: true }), + file("components/Comp.ts", serverFlags), + file("routes/index.ts", { ...serverFlags, isRoute: true }), + ], + clientEdges: [], + serverEdges: [["routes/index.ts", "components/Comp.ts"]], + }); + }, + }); +}); + +// The dev server is spawned with the project root as its cwd, which is where +// `.bake-debug` is created. +function readDump(dev: Dev, file: string) { + return readFileSync(path.join(dev.rootDir, ".bake-debug", file), "utf8"); +} + +/** Asserts the two comment lines every non-source-map dump starts with and returns what follows them. */ +function stripDumpHeader(dump: string, fileName: string, graph: "client" | "server") { + const header = dump.match(/^\/\/ (".*") bundled for (\w+)\n\/\/ Bundled at \d+, Bun (\S+)\n/); + expect(header && { fileName: header[1], graph: header[2], version: header[3] }).toEqual({ + fileName: `"${fileName}"`, + graph, + version: Bun.version, + }); + return dump.slice(header![0].length); +} + +describe.skipIf(!isDebug)(".bake-debug dumps", () => { + devTest("client bundles are dumped to .bake-debug", { + files: { + "index.html": emptyHtmlFile({ scripts: ["index.ts"] }), + "index.ts": ` + import { value } from "./dep"; + console.log("index says", value); + `, + "dep.ts": `export const value = "dump me";`, + }, + async test(dev) { + const dumpDir = path.join(dev.rootDir, ".bake-debug"); + // Created when the dev server starts; populated as things get bundled. + expect({ dumpDir: existsSync(dumpDir), client: existsSync(path.join(dumpDir, "client")) }).toEqual({ + dumpDir: true, + client: false, + }); + + const html = await dev.fetch("/").text(); + + // Every module is written as it is bundled, wrapped so that the file parses on its own. + const dep = stripDumpHeader(readDump(dev, "client/dep.ts"), "dep.ts", "client"); + expect(dep).toMatch(/^\(\{\n[^]*"dump me"[^]*\}\);\n$/); + stripDumpHeader(readDump(dev, "client/index.ts"), "index.ts", "client"); + + // The chunk and the source map handed to the browser are dumped as the + // latest ones, byte for byte. + const scriptUrl = html.match(/src="([^"]+\.js)"/)![1]; + const script = await dev.fetch(scriptUrl).text(); + expect(stripDumpHeader(readDump(dev, "client/latest_chunk.js"), "latest_chunk.js", "client")).toBe(script); + + const sourceMapUrl = script.match(/\n\/\/# sourceMappingURL=(\S+)/)![1]; + const sourceMap = await dev.fetch(sourceMapUrl).text(); + expect(readDump(dev, "client/latest_chunk.js.map")).toBe(sourceMap); + }, + }); + + devTest("server bundles are dumped to .bake-debug", { + framework: minimalFramework, + files: { + "db.ts": `export const abc = "server dump";`, + "routes/index.ts": ` + import { abc } from "../db"; + export default function (req, meta) { + return new Response(abc); + } + `, + }, + async test(dev) { + await dev.fetch("/").equals("server dump"); + + expect(stripDumpHeader(readDump(dev, "server/db.ts"), "db.ts", "server")).toMatch( + /^\(\{\n[^]*"server dump"[^]*\}\);\n$/, + ); + expect(readDump(dev, path.join("server", "routes", "index.ts"))).toContain(" bundled for server\n"); + + // Files outside the project root (here the framework's server entry + // point) keep their relative path, with each `..` made into a directory name. + const frameworkEntry = realpathSync(minimalFramework.fileSystemRouterTypes[0].serverEntryPoint!); + const escaped = path.relative(dev.rootDir, frameworkEntry).replaceAll(".." + path.sep, "_.._" + path.sep); + expect(escaped.startsWith("_.._" + path.sep)).toBe(true); + expect(readDump(dev, path.join("server", escaped))).toContain(" bundled for server\n"); + + // The server chunk is loaded straight into the server VM; the dump is + // the only way to read it, alongside the source map it was loaded with. + const chunk = stripDumpHeader(readDump(dev, "server/latest_hmr.js"), "latest_hmr.js", "server"); + expect(chunk).toContain("server dump"); + const sourceMap = JSON.parse(readDump(dev, "server/latest_hmr.js.map")); + expect(sourceMap.sources).toContain(dev.join("db.ts")); + }, + }); +});