Add comprehensive bundler graph visualizer for debugging

- Implements GraphVisualizer that dumps complete LinkerContext state to JSON
- Captures files, symbols, imports/exports, chunks, parts, and dependency graph
- Controlled via BUN_BUNDLER_GRAPH_DUMP environment variable (all/scan/chunks/compute/link)
- Uses proper bun.json.toAST and js_printer.printJSON for correct JSON serialization
- Enhanced json.toAST to support custom toExprForJSON methods and BabyList-like types
- Includes interactive D3.js HTML visualizer with multiple views
- Helps debug duplicate exports, circular dependencies, and bundling issues
- Outputs to /tmp/bun-bundler-debug/ with timestamped files

Usage: BUN_BUNDLER_GRAPH_DUMP=all bun build file.js

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Claude
2025-08-28 03:39:16 +02:00
parent 437e15bae5
commit bc32ddfbd3
5 changed files with 1594 additions and 2 deletions

View File

@@ -414,6 +414,7 @@ src/bundler/BundleThread.zig
src/bundler/Chunk.zig
src/bundler/DeferredBatchTask.zig
src/bundler/entry_points.zig
src/bundler/graph_visualizer.zig
src/bundler/Graph.zig
src/bundler/HTMLImportManifest.zig
src/bundler/linker_context/computeChunks.zig

View File

@@ -4,6 +4,7 @@ pub const LinkerContext = struct {
pub const OutputFileListBuilder = @import("./linker_context/OutputFileListBuilder.zig");
pub const StaticRouteVisitor = @import("./linker_context/StaticRouteVisitor.zig");
pub const GraphVisualizer = @import("./graph_visualizer.zig").GraphVisualizer;
parse_graph: *Graph = undefined,
graph: LinkerGraph = undefined,
@@ -392,6 +393,13 @@ pub const LinkerContext = struct {
}
try this.scanImportsAndExports();
// Dump graph state after scan
if (comptime Environment.isDebug) {
GraphVisualizer.dumpGraphState(this, "after_scan", null) catch |err| {
debug("Failed to dump graph after scan: {}", .{err});
};
}
// Stop now if there were errors
if (this.log.hasErrors()) {
@@ -409,18 +417,39 @@ pub const LinkerContext = struct {
}
const chunks = try this.computeChunks(bundle.unique_key);
// Dump graph state after computing chunks
if (comptime Environment.isDebug) {
GraphVisualizer.dumpGraphState(this, "after_chunks", chunks) catch |err| {
debug("Failed to dump graph after chunks: {}", .{err});
};
}
if (comptime FeatureFlags.help_catch_memory_issues) {
this.checkForMemoryCorruption();
}
try this.computeCrossChunkDependencies(chunks);
// Dump graph state after computing dependencies
if (comptime Environment.isDebug) {
GraphVisualizer.dumpGraphState(this, "after_compute", chunks) catch |err| {
debug("Failed to dump graph after compute: {}", .{err});
};
}
if (comptime FeatureFlags.help_catch_memory_issues) {
this.checkForMemoryCorruption();
}
this.graph.symbols.followAll();
// Final dump after linking
if (comptime Environment.isDebug) {
GraphVisualizer.dumpGraphState(this, "after_link", chunks) catch |err| {
debug("Failed to dump graph after link: {}", .{err});
};
}
return chunks;
}

View File

@@ -0,0 +1,990 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bun Bundler Graph Visualizer</title>
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
<script src="https://cdn.jsdelivr.net/npm/dagre-d3@0.6.4/dist/dagre-d3.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #1e1e1e;
color: #d4d4d4;
height: 100vh;
display: flex;
flex-direction: column;
}
#header {
background: #2d2d30;
padding: 10px 20px;
border-bottom: 1px solid #3e3e42;
display: flex;
align-items: center;
gap: 20px;
flex-shrink: 0;
}
#header h1 {
font-size: 20px;
font-weight: 600;
color: #fff;
}
#controls {
display: flex;
gap: 10px;
align-items: center;
}
#controls button, #controls select, #controls input {
background: #3e3e42;
color: #cccccc;
border: 1px solid #555;
padding: 5px 10px;
border-radius: 3px;
cursor: pointer;
font-size: 13px;
}
#controls button:hover, #controls select:hover {
background: #4e4e52;
}
#container {
display: flex;
flex: 1;
overflow: hidden;
}
#sidebar {
width: 350px;
background: #252526;
border-right: 1px solid #3e3e42;
display: flex;
flex-direction: column;
overflow: hidden;
}
#tabs {
display: flex;
background: #2d2d30;
border-bottom: 1px solid #3e3e42;
}
.tab {
padding: 10px 20px;
cursor: pointer;
border-right: 1px solid #3e3e42;
font-size: 13px;
user-select: none;
}
.tab.active {
background: #1e1e1e;
color: #fff;
}
#tab-content {
flex: 1;
overflow-y: auto;
padding: 15px;
}
#main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
#graph-container {
flex: 1;
background: #1e1e1e;
overflow: hidden;
position: relative;
}
#graph-svg {
width: 100%;
height: 100%;
}
#details {
height: 200px;
background: #252526;
border-top: 1px solid #3e3e42;
overflow-y: auto;
padding: 15px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
}
.file-item, .symbol-item, .chunk-item {
padding: 8px;
margin: 2px 0;
background: #2d2d30;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
word-break: break-all;
}
.file-item:hover, .symbol-item:hover, .chunk-item:hover {
background: #3e3e42;
}
.file-item.selected, .symbol-item.selected, .chunk-item.selected {
background: #094771;
border: 1px solid #007ACC;
}
.search-box {
width: 100%;
padding: 8px;
background: #3e3e42;
border: 1px solid #555;
color: #cccccc;
margin-bottom: 10px;
border-radius: 3px;
font-size: 12px;
}
.node {
cursor: pointer;
}
.node rect {
stroke: #333;
stroke-width: 1.5px;
}
.node.entry-point rect {
stroke: #4EC9B0;
stroke-width: 2px;
fill: #2d5a4e !important;
}
.node.has-exports rect {
stroke: #569CD6;
stroke-width: 2px;
}
.node.css-file rect {
fill: #4e3a5a !important;
}
.edge {
stroke: #555;
stroke-width: 1.5px;
fill: none;
marker-end: url(#arrowhead);
}
.edge.dynamic-import {
stroke-dasharray: 5, 5;
stroke: #CE9178;
}
.edge.css-import {
stroke: #b267e6;
}
#legend {
position: absolute;
top: 10px;
right: 10px;
background: rgba(30, 30, 30, 0.95);
border: 1px solid #3e3e42;
padding: 10px;
border-radius: 3px;
font-size: 12px;
}
.legend-item {
margin: 5px 0;
display: flex;
align-items: center;
gap: 8px;
}
.legend-color {
width: 20px;
height: 14px;
border-radius: 2px;
}
.stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 15px;
}
.stat-item {
background: #2d2d30;
padding: 10px;
border-radius: 3px;
}
.stat-value {
font-size: 20px;
font-weight: bold;
color: #4EC9B0;
}
.stat-label {
font-size: 11px;
color: #808080;
margin-top: 3px;
}
pre {
background: #2d2d30;
padding: 10px;
border-radius: 3px;
overflow-x: auto;
}
.highlight {
background: #3e5c1e;
animation: pulse 1s;
}
@keyframes pulse {
0% { background: #5e8c2e; }
100% { background: #3e5c1e; }
}
</style>
</head>
<body>
<div id="header">
<h1>🚀 Bun Bundler Graph Visualizer</h1>
<div id="controls">
<input type="file" id="file-input" accept=".json" style="display: none;">
<button onclick="document.getElementById('file-input').click()">📁 Load JSON</button>
<select id="view-mode">
<option value="files">Files View</option>
<option value="chunks">Chunks View</option>
<option value="dependencies">Dependencies View</option>
<option value="symbols">Symbols View</option>
</select>
<select id="layout-mode">
<option value="dagre">Hierarchical</option>
<option value="force">Force-Directed</option>
<option value="circular">Circular</option>
</select>
<button id="zoom-fit">🔍 Fit</button>
<button id="zoom-in"></button>
<button id="zoom-out"></button>
<button id="export-svg">💾 Export SVG</button>
</div>
</div>
<div id="container">
<div id="sidebar">
<div id="tabs">
<div class="tab active" data-tab="overview">Overview</div>
<div class="tab" data-tab="files">Files</div>
<div class="tab" data-tab="symbols">Symbols</div>
<div class="tab" data-tab="chunks">Chunks</div>
</div>
<div id="tab-content">
<div id="overview-content" class="tab-pane">
<h3 style="margin-bottom: 15px;">Graph Statistics</h3>
<div class="stats">
<div class="stat-item">
<div class="stat-value" id="stat-files">-</div>
<div class="stat-label">Total Files</div>
</div>
<div class="stat-item">
<div class="stat-value" id="stat-reachable">-</div>
<div class="stat-label">Reachable Files</div>
</div>
<div class="stat-item">
<div class="stat-value" id="stat-chunks">-</div>
<div class="stat-label">Chunks</div>
</div>
<div class="stat-item">
<div class="stat-value" id="stat-symbols">-</div>
<div class="stat-label">Symbols</div>
</div>
<div class="stat-item">
<div class="stat-value" id="stat-exports">-</div>
<div class="stat-label">Exports</div>
</div>
<div class="stat-item">
<div class="stat-value" id="stat-imports">-</div>
<div class="stat-label">Imports</div>
</div>
</div>
<h3 style="margin: 15px 0;">Metadata</h3>
<div id="metadata-content"></div>
</div>
<div id="files-content" class="tab-pane" style="display: none;">
<input type="text" class="search-box" placeholder="Search files..." id="files-search">
<div id="files-list"></div>
</div>
<div id="symbols-content" class="tab-pane" style="display: none;">
<input type="text" class="search-box" placeholder="Search symbols..." id="symbols-search">
<div id="symbols-list"></div>
</div>
<div id="chunks-content" class="tab-pane" style="display: none;">
<div id="chunks-list"></div>
</div>
</div>
</div>
<div id="main">
<div id="graph-container">
<svg id="graph-svg">
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7"
refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#666" />
</marker>
</defs>
</svg>
<div id="legend">
<div class="legend-item">
<div class="legend-color" style="background: #2d5a4e; border: 2px solid #4EC9B0;"></div>
<span>Entry Point</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #3e3e42; border: 2px solid #569CD6;"></div>
<span>Has Exports</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #4e3a5a;"></div>
<span>CSS File</span>
</div>
<div class="legend-item">
<div style="width: 20px; height: 2px; background: #CE9178; border-bottom: 2px dashed #CE9178;"></div>
<span>Dynamic Import</span>
</div>
</div>
</div>
<div id="details">
<div style="color: #808080;">Select a node to view details...</div>
</div>
</div>
</div>
<script>
let graphData = null;
let currentView = 'files';
let currentLayout = 'dagre';
let svg = d3.select('#graph-svg');
let g = svg.append('g');
let zoom = d3.zoom()
.scaleExtent([0.1, 10])
.on('zoom', (event) => {
g.attr('transform', event.transform);
});
svg.call(zoom);
// Tab switching
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-pane').forEach(p => p.style.display = 'none');
tab.classList.add('active');
document.getElementById(tab.dataset.tab + '-content').style.display = 'block';
});
});
// File input handler
document.getElementById('file-input').addEventListener('change', async (event) => {
const file = event.target.files[0];
if (file) {
const text = await file.text();
graphData = JSON.parse(text);
updateUI();
renderGraph();
}
});
// View mode change
document.getElementById('view-mode').addEventListener('change', (event) => {
currentView = event.target.value;
renderGraph();
});
// Layout mode change
document.getElementById('layout-mode').addEventListener('change', (event) => {
currentLayout = event.target.value;
renderGraph();
});
// Zoom controls
document.getElementById('zoom-fit').addEventListener('click', () => {
const bounds = g.node().getBBox();
const width = svg.node().clientWidth;
const height = svg.node().clientHeight;
const scale = 0.9 * Math.min(width / bounds.width, height / bounds.height);
const transform = d3.zoomIdentity
.translate(width / 2 - scale * (bounds.x + bounds.width / 2),
height / 2 - scale * (bounds.y + bounds.height / 2))
.scale(scale);
svg.transition().duration(750).call(zoom.transform, transform);
});
document.getElementById('zoom-in').addEventListener('click', () => {
svg.transition().duration(300).call(zoom.scaleBy, 1.3);
});
document.getElementById('zoom-out').addEventListener('click', () => {
svg.transition().duration(300).call(zoom.scaleBy, 0.7);
});
// Export SVG
document.getElementById('export-svg').addEventListener('click', () => {
const svgData = svg.node().outerHTML;
const blob = new Blob([svgData], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `bundler-graph-${Date.now()}.svg`;
a.click();
URL.revokeObjectURL(url);
});
function updateUI() {
if (!graphData) return;
// Update stats
document.getElementById('stat-files').textContent =
graphData.metadata?.totalFiles || '-';
document.getElementById('stat-reachable').textContent =
graphData.metadata?.reachableFiles || '-';
document.getElementById('stat-chunks').textContent =
graphData.chunks?.length || '-';
document.getElementById('stat-symbols').textContent =
graphData.symbols?.totalSymbols || '-';
document.getElementById('stat-exports').textContent =
graphData.importsAndExports?.totalExports || '-';
document.getElementById('stat-imports').textContent =
graphData.importsAndExports?.totalImports || '-';
// Update metadata
const metadataHtml = Object.entries(graphData.metadata || {})
.filter(([key]) => !key.startsWith('total'))
.map(([key, value]) => `
<div style="margin: 5px 0;">
<strong>${key}:</strong> ${value}
</div>
`).join('');
document.getElementById('metadata-content').innerHTML = metadataHtml;
// Update files list
const filesList = graphData.files || [];
const filesHtml = filesList.map((file, idx) => `
<div class="file-item" data-index="${idx}">
<strong>${file.path || `File ${idx}`}</strong>
<div style="font-size: 11px; color: #808080;">
${file.loader || 'unknown'} | ${file.partCount || 0} parts
</div>
</div>
`).join('');
document.getElementById('files-list').innerHTML = filesHtml;
// Update symbols list
const symbolsHtml = (graphData.symbols?.bySource || [])
.flatMap(source =>
(source.samples || []).map(symbol => `
<div class="symbol-item">
<strong>${symbol.originalName}</strong>
<div style="font-size: 11px; color: #808080;">
${symbol.kind} | Source ${source.sourceIndex}
</div>
</div>
`)
).join('');
document.getElementById('symbols-list').innerHTML = symbolsHtml;
// Update chunks list
const chunksHtml = (graphData.chunks || []).map((chunk, idx) => `
<div class="chunk-item" data-index="${idx}">
<strong>Chunk ${idx}</strong>
<div style="font-size: 11px; color: #808080;">
${chunk.isEntryPoint ? 'Entry Point | ' : ''}
${chunk.filesInChunkCount || 0} files
</div>
</div>
`).join('');
document.getElementById('chunks-list').innerHTML = chunksHtml;
// Add search functionality
document.getElementById('files-search').addEventListener('input', (e) => {
const search = e.target.value.toLowerCase();
document.querySelectorAll('.file-item').forEach(item => {
const visible = item.textContent.toLowerCase().includes(search);
item.style.display = visible ? 'block' : 'none';
});
});
document.getElementById('symbols-search').addEventListener('input', (e) => {
const search = e.target.value.toLowerCase();
document.querySelectorAll('.symbol-item').forEach(item => {
const visible = item.textContent.toLowerCase().includes(search);
item.style.display = visible ? 'block' : 'none';
});
});
// Add click handlers for selection
document.querySelectorAll('.file-item').forEach(item => {
item.addEventListener('click', () => {
document.querySelectorAll('.file-item').forEach(i => i.classList.remove('selected'));
item.classList.add('selected');
const idx = parseInt(item.dataset.index);
showFileDetails(graphData.files[idx], idx);
});
});
}
function showFileDetails(file, index) {
const detailsHtml = `
<h3>File: ${file.path || `File ${index}`}</h3>
<pre>${JSON.stringify(file, null, 2)}</pre>
`;
document.getElementById('details').innerHTML = detailsHtml;
}
function renderGraph() {
if (!graphData) return;
// Clear existing graph
g.selectAll('*').remove();
switch (currentView) {
case 'files':
renderFilesGraph();
break;
case 'chunks':
renderChunksGraph();
break;
case 'dependencies':
renderDependencyGraph();
break;
case 'symbols':
renderSymbolsGraph();
break;
}
}
function renderFilesGraph() {
const files = graphData.files || [];
const edges = graphData.dependencyGraph?.edges || [];
// Create nodes
const nodes = files.map((file, idx) => ({
id: `file-${idx}`,
label: file.path?.split('/').pop() || `File ${idx}`,
data: file,
index: idx
}));
// Create edges from dependency graph
const edgeMap = new Map();
edges.forEach(edge => {
const key = `${edge.from.source}-${edge.to.source}`;
if (!edgeMap.has(key) && edge.from.source !== edge.to.source) {
edgeMap.set(key, {
source: `file-${edge.from.source}`,
target: `file-${edge.to.source}`
});
}
});
const uniqueEdges = Array.from(edgeMap.values());
if (currentLayout === 'dagre') {
renderDagreLayout(nodes, uniqueEdges);
} else if (currentLayout === 'force') {
renderForceLayout(nodes, uniqueEdges);
} else {
renderCircularLayout(nodes, uniqueEdges);
}
}
function renderChunksGraph() {
const chunks = graphData.chunks || [];
const nodes = chunks.map((chunk, idx) => ({
id: `chunk-${idx}`,
label: `Chunk ${idx}`,
data: chunk,
index: idx
}));
// Create edges based on cross-chunk imports
const edges = [];
chunks.forEach((chunk, idx) => {
if (chunk.crossChunkImports) {
// This would need actual cross-chunk import data
// For now, we'll create synthetic edges
}
});
if (currentLayout === 'dagre') {
renderDagreLayout(nodes, edges);
} else if (currentLayout === 'force') {
renderForceLayout(nodes, edges);
} else {
renderCircularLayout(nodes, edges);
}
}
function renderDependencyGraph() {
const edges = graphData.dependencyGraph?.edges || [];
const files = graphData.files || [];
// Create unique nodes from edges
const nodeMap = new Map();
edges.forEach(edge => {
const fromId = `${edge.from.source}-${edge.from.part}`;
const toId = `${edge.to.source}-${edge.to.part}`;
if (!nodeMap.has(fromId)) {
const file = files[edge.from.source];
nodeMap.set(fromId, {
id: fromId,
label: `${file?.path?.split('/').pop() || edge.from.source}:${edge.from.part}`,
data: { source: edge.from.source, part: edge.from.part }
});
}
if (!nodeMap.has(toId)) {
const file = files[edge.to.source];
nodeMap.set(toId, {
id: toId,
label: `${file?.path?.split('/').pop() || edge.to.source}:${edge.to.part}`,
data: { source: edge.to.source, part: edge.to.part }
});
}
});
const nodes = Array.from(nodeMap.values());
const graphEdges = edges.slice(0, 500).map(edge => ({
source: `${edge.from.source}-${edge.from.part}`,
target: `${edge.to.source}-${edge.to.part}`
}));
if (currentLayout === 'dagre') {
renderDagreLayout(nodes, graphEdges);
} else if (currentLayout === 'force') {
renderForceLayout(nodes, graphEdges);
} else {
renderCircularLayout(nodes, graphEdges);
}
}
function renderSymbolsGraph() {
const symbols = graphData.importsAndExports || {};
const exports = symbols.exportSamples || [];
const imports = symbols.importSamples || [];
// Create nodes for files with exports/imports
const nodeMap = new Map();
const edges = [];
exports.forEach(exp => {
const nodeId = `file-${exp.source}`;
if (!nodeMap.has(nodeId)) {
nodeMap.set(nodeId, {
id: nodeId,
label: `Source ${exp.source}`,
data: { exports: [], imports: [] }
});
}
nodeMap.get(nodeId).data.exports.push(exp);
});
imports.forEach(imp => {
const nodeId = `file-${imp.source}`;
if (!nodeMap.has(nodeId)) {
nodeMap.set(nodeId, {
id: nodeId,
label: `Source ${imp.source}`,
data: { exports: [], imports: [] }
});
}
nodeMap.get(nodeId).data.imports.push(imp);
if (imp.targetSource !== undefined) {
edges.push({
source: nodeId,
target: `file-${imp.targetSource}`
});
}
});
const nodes = Array.from(nodeMap.values());
if (currentLayout === 'dagre') {
renderDagreLayout(nodes, edges);
} else if (currentLayout === 'force') {
renderForceLayout(nodes, edges);
} else {
renderCircularLayout(nodes, edges);
}
}
function renderDagreLayout(nodes, edges) {
// Create a new directed graph
const dagreGraph = new dagreD3.graphlib.Graph()
.setGraph({ rankdir: 'TB', nodesep: 50, ranksep: 50 })
.setDefaultEdgeLabel(() => ({}));
// Add nodes
nodes.forEach(node => {
dagreGraph.setNode(node.id, {
label: node.label,
width: Math.max(node.label.length * 7, 100),
height: 40,
rx: 5,
ry: 5
});
});
// Add edges
edges.forEach(edge => {
if (dagreGraph.hasNode(edge.source) && dagreGraph.hasNode(edge.target)) {
dagreGraph.setEdge(edge.source, edge.target);
}
});
// Layout
dagre.layout(dagreGraph);
// Render nodes
const nodeSelection = g.selectAll('.node')
.data(nodes)
.enter()
.append('g')
.attr('class', d => {
let classes = 'node';
if (d.data?.entryPointKind === 'user_specified') classes += ' entry-point';
if (d.data?.namedExportsCount > 0) classes += ' has-exports';
if (d.data?.loader === 'css') classes += ' css-file';
return classes;
})
.attr('transform', d => {
const node = dagreGraph.node(d.id);
return `translate(${node.x},${node.y})`;
});
nodeSelection.append('rect')
.attr('x', d => -dagreGraph.node(d.id).width / 2)
.attr('y', -20)
.attr('width', d => dagreGraph.node(d.id).width)
.attr('height', 40)
.attr('rx', 5)
.attr('fill', '#3e3e42');
nodeSelection.append('text')
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', '#d4d4d4')
.style('font-size', '12px')
.text(d => d.label);
// Render edges
g.selectAll('.edge')
.data(edges)
.enter()
.append('path')
.attr('class', 'edge')
.attr('d', d => {
const edge = dagreGraph.edge(d.source, d.target);
if (!edge || !edge.points) return '';
const line = d3.line()
.x(p => p.x)
.y(p => p.y)
.curve(d3.curveBasis);
return line(edge.points);
});
// Add interactivity
nodeSelection.on('click', (event, d) => {
showFileDetails(d.data, d.index);
// Highlight connected edges
g.selectAll('.edge')
.style('stroke', e =>
(e.source === d.id || e.target === d.id) ? '#4EC9B0' : '#555')
.style('stroke-width', e =>
(e.source === d.id || e.target === d.id) ? 2.5 : 1.5);
});
}
function renderForceLayout(nodes, edges) {
const width = svg.node().clientWidth;
const height = svg.node().clientHeight;
const simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(edges).id(d => d.id).distance(100))
.force('charge', d3.forceManyBody().strength(-300))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius(50));
// Render edges
const link = g.selectAll('.edge')
.data(edges)
.enter()
.append('line')
.attr('class', 'edge');
// Render nodes
const node = g.selectAll('.node')
.data(nodes)
.enter()
.append('g')
.attr('class', d => {
let classes = 'node';
if (d.data?.entryPointKind === 'user_specified') classes += ' entry-point';
if (d.data?.namedExportsCount > 0) classes += ' has-exports';
if (d.data?.loader === 'css') classes += ' css-file';
return classes;
})
.call(d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended));
node.append('rect')
.attr('x', -40)
.attr('y', -20)
.attr('width', 80)
.attr('height', 40)
.attr('rx', 5)
.attr('fill', '#3e3e42');
node.append('text')
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', '#d4d4d4')
.style('font-size', '11px')
.text(d => d.label.length > 12 ? d.label.substring(0, 12) + '...' : d.label);
simulation.on('tick', () => {
link
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y);
node.attr('transform', d => `translate(${d.x},${d.y})`);
});
function dragstarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
}
function dragended(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
node.on('click', (event, d) => {
showFileDetails(d.data, d.index);
});
}
function renderCircularLayout(nodes, edges) {
const width = svg.node().clientWidth;
const height = svg.node().clientHeight;
const radius = Math.min(width, height) / 2 - 100;
const centerX = width / 2;
const centerY = height / 2;
// Position nodes in a circle
nodes.forEach((node, i) => {
const angle = (i / nodes.length) * 2 * Math.PI;
node.x = centerX + radius * Math.cos(angle);
node.y = centerY + radius * Math.sin(angle);
});
// Render edges
const link = g.selectAll('.edge')
.data(edges)
.enter()
.append('path')
.attr('class', 'edge')
.attr('d', d => {
const source = nodes.find(n => n.id === d.source);
const target = nodes.find(n => n.id === d.target);
if (!source || !target) return '';
// Create curved path for better visibility
const dx = target.x - source.x;
const dy = target.y - source.y;
const dr = Math.sqrt(dx * dx + dy * dy);
return `M${source.x},${source.y}A${dr},${dr} 0 0,1 ${target.x},${target.y}`;
})
.style('fill', 'none');
// Render nodes
const node = g.selectAll('.node')
.data(nodes)
.enter()
.append('g')
.attr('class', d => {
let classes = 'node';
if (d.data?.entryPointKind === 'user_specified') classes += ' entry-point';
if (d.data?.namedExportsCount > 0) classes += ' has-exports';
if (d.data?.loader === 'css') classes += ' css-file';
return classes;
})
.attr('transform', d => `translate(${d.x},${d.y})`);
node.append('circle')
.attr('r', 25)
.attr('fill', '#3e3e42');
node.append('text')
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', '#d4d4d4')
.style('font-size', '10px')
.text(d => {
const label = d.label.split('/').pop();
return label.length > 10 ? label.substring(0, 10) + '...' : label;
});
node.on('click', (event, d) => {
showFileDetails(d.data, d.index);
});
}
// Auto-load if URL has a graph parameter
const urlParams = new URLSearchParams(window.location.search);
const graphFile = urlParams.get('graph');
if (graphFile) {
fetch(graphFile)
.then(res => res.json())
.then(data => {
graphData = data;
updateUI();
renderGraph();
})
.catch(err => console.error('Failed to load graph:', err));
}
</script>
</body>
</html>

View File

@@ -0,0 +1,551 @@
const std = @import("std");
const bun = @import("bun");
const string = bun.string;
const Output = bun.Output;
const Global = bun.Global;
const Environment = bun.Environment;
const strings = bun.strings;
const MutableString = bun.MutableString;
const stringZ = bun.stringZ;
const default_allocator = bun.default_allocator;
const C = bun.C;
const JSC = bun.JSC;
const js_ast = bun.ast;
const bundler = bun.bundle_v2;
const Index = js_ast.Index;
const Ref = js_ast.Ref;
const Symbol = js_ast.Symbol;
const ImportRecord = bun.ImportRecord;
const DeclaredSymbol = js_ast.DeclaredSymbol;
const logger = bun.logger;
const Part = js_ast.Part;
const Chunk = bundler.Chunk;
const js_printer = bun.js_printer;
const JSON = bun.json;
const JSAst = bun.ast;
pub const GraphVisualizer = struct {
const debug = Output.scoped(.GraphViz, .visible);
pub fn shouldDump() bool {
if (comptime !Environment.isDebug) return false;
return bun.getenvZ("BUN_BUNDLER_GRAPH_DUMP") != null;
}
pub fn getDumpStage() DumpStage {
const env_val = bun.getenvZ("BUN_BUNDLER_GRAPH_DUMP") orelse return .none;
if (strings.eqlComptime(env_val, "all")) return .all;
if (strings.eqlComptime(env_val, "scan")) return .after_scan;
if (strings.eqlComptime(env_val, "compute")) return .after_compute;
if (strings.eqlComptime(env_val, "chunks")) return .after_chunks;
if (strings.eqlComptime(env_val, "link")) return .after_link;
return .all; // Default to all if set but not recognized
}
pub const DumpStage = enum {
none,
after_scan,
after_compute,
after_chunks,
after_link,
all,
};
pub fn dumpGraphState(
ctx: *bundler.LinkerContext,
stage: []const u8,
chunks: ?[]const Chunk,
) !void {
if (!shouldDump()) return;
const dump_stage = getDumpStage();
const should_dump_now = switch (dump_stage) {
.none => false,
.all => true,
.after_scan => strings.eqlComptime(stage, "after_scan"),
.after_compute => strings.eqlComptime(stage, "after_compute"),
.after_chunks => strings.eqlComptime(stage, "after_chunks"),
.after_link => strings.eqlComptime(stage, "after_link"),
};
if (!should_dump_now) return;
debug("Dumping graph state: {s}", .{stage});
var arena = std.heap.ArenaAllocator.init(default_allocator);
defer arena.deinit();
const allocator = arena.allocator();
// Create output directory
const output_dir = "/tmp/bun-bundler-debug";
std.fs.cwd().makePath(output_dir) catch |err| {
debug("Failed to create output directory: {}", .{err});
return;
};
// Generate filename with timestamp
const timestamp = std.time.milliTimestamp();
const filename = try std.fmt.allocPrint(allocator, "{s}/bundler_graph_{s}_{d}.json", .{
output_dir,
stage,
timestamp,
});
// Build the graph data structure
const graph_data = try buildGraphData(ctx, allocator, stage, timestamp, chunks);
// Convert to JSON AST
const json_ast = try JSON.toAST(allocator, GraphData, graph_data);
// Print JSON to buffer
var stack_fallback = std.heap.stackFallback(1024 * 1024, allocator); // 1MB stack fallback
const print_allocator = stack_fallback.get();
const buffer_writer = js_printer.BufferWriter.init(print_allocator);
var writer = js_printer.BufferPrinter.init(buffer_writer);
defer writer.ctx.buffer.deinit();
const source = &logger.Source.initEmptyFile(filename);
_ = js_printer.printJSON(
*js_printer.BufferPrinter,
&writer,
json_ast,
source,
.{ .mangled_props = null },
) catch |err| {
debug("Failed to print JSON: {}", .{err});
return;
};
// Write to file
const file = try std.fs.cwd().createFile(filename, .{});
defer file.close();
try file.writeAll(writer.ctx.buffer.list.items);
debug("Graph dump written to: {s}", .{filename});
// Also generate the visualizer HTML
try generateVisualizerHTML(allocator, output_dir, timestamp);
}
const GraphData = struct {
stage: []const u8,
timestamp: i64,
metadata: Metadata,
files: []FileData,
symbols: SymbolData,
entry_points: []EntryPointData,
imports_and_exports: ImportsExports,
chunks: ?[]ChunkData,
dependency_graph: DependencyGraph,
};
const Metadata = struct {
total_files: usize,
reachable_files: usize,
entry_points: usize,
code_splitting: bool,
output_format: []const u8,
target: []const u8,
tree_shaking: bool,
minify: bool,
};
const FileData = struct {
index: usize,
path: []const u8,
loader: []const u8,
source_length: usize,
entry_point_kind: []const u8,
part_count: usize,
parts: ?[]PartData,
named_exports_count: usize,
named_imports_count: usize,
flags: FileFlags,
};
const FileFlags = struct {
is_async: bool,
needs_exports_variable: bool,
needs_synthetic_default_export: bool,
wrap: []const u8,
};
const PartData = struct {
index: usize,
stmt_count: usize,
import_record_count: usize,
declared_symbol_count: usize,
can_be_removed_if_unused: bool,
force_tree_shaking: bool,
symbol_uses: []SymbolUse,
dependencies: []PartDependency,
};
const SymbolUse = struct {
ref: []const u8,
count: u32,
};
const PartDependency = struct {
source: u32,
part: u32,
};
const SymbolData = struct {
total_symbols: usize,
by_source: []SourceSymbols,
};
const SourceSymbols = struct {
source_index: usize,
symbol_count: usize,
symbols: []SymbolInfo,
};
const SymbolInfo = struct {
inner_index: usize,
kind: []const u8,
original_name: []const u8,
link: ?[]const u8,
};
const EntryPointData = struct {
source_index: u32,
output_path: []const u8,
};
const ImportsExports = struct {
total_exports: usize,
total_imports: usize,
total_import_records: usize,
exports: []ExportInfo,
imports: []ImportInfo,
};
const ExportInfo = struct {
source: u32,
name: []const u8,
ref: []const u8,
};
const ImportInfo = struct {
source: u32,
kind: []const u8,
path: []const u8,
target_source: ?u32,
};
const ChunkData = struct {
index: usize,
is_entry_point: bool,
source_index: u32,
files_in_chunk: []u32,
cross_chunk_import_count: usize,
};
const DependencyGraph = struct {
edges: []GraphEdge,
};
const GraphEdge = struct {
from: NodeRef,
to: NodeRef,
};
const NodeRef = struct {
source: u32,
part: u32,
};
fn buildGraphData(
ctx: *bundler.LinkerContext,
allocator: std.mem.Allocator,
stage: []const u8,
timestamp: i64,
chunks: ?[]const Chunk,
) !GraphData {
const sources = ctx.parse_graph.input_files.items(.source);
const loaders = ctx.parse_graph.input_files.items(.loader);
const ast_list = ctx.graph.ast.slice();
const meta_list = ctx.graph.meta.slice();
const files_list = ctx.graph.files.slice();
// Build metadata
const metadata = Metadata{
.total_files = ctx.graph.files.len,
.reachable_files = ctx.graph.reachable_files.len,
.entry_points = ctx.graph.entry_points.len,
.code_splitting = ctx.graph.code_splitting,
.output_format = @tagName(ctx.options.output_format),
.target = @tagName(ctx.options.target),
.tree_shaking = ctx.options.tree_shaking,
.minify = ctx.options.minify_syntax,
};
// Build file data
var file_data_list = try allocator.alloc(FileData, ctx.graph.files.len);
for (0..ctx.graph.files.len) |i| {
var parts_data: ?[]PartData = null;
if (i < ast_list.items(.parts).len) {
const parts = ast_list.items(.parts)[i].slice();
if (parts.len > 0) {
parts_data = try allocator.alloc(PartData, parts.len);
for (parts, 0..) |part, j| {
// Build symbol uses
var symbol_uses = try allocator.alloc(SymbolUse, part.symbol_uses.count());
var use_idx: usize = 0;
var use_iter = part.symbol_uses.iterator();
while (use_iter.next()) |entry| : (use_idx += 1) {
symbol_uses[use_idx] = .{
.ref = try std.fmt.allocPrint(allocator, "{}", .{entry.key_ptr.*}),
.count = entry.value_ptr.count_estimate,
};
}
// Build dependencies
var deps = try allocator.alloc(PartDependency, part.dependencies.len);
for (part.dependencies.slice(), 0..) |dep, k| {
deps[k] = .{
.source = dep.source_index.get(),
.part = dep.part_index,
};
}
parts_data.?[j] = .{
.index = j,
.stmt_count = part.stmts.len,
.import_record_count = part.import_record_indices.len,
.declared_symbol_count = part.declared_symbols.entries.len,
.can_be_removed_if_unused = part.can_be_removed_if_unused,
.force_tree_shaking = part.force_tree_shaking,
.symbol_uses = symbol_uses,
.dependencies = deps,
};
}
}
}
const path = if (i < sources.len) sources[i].path.text else "unknown";
const loader = if (i < loaders.len) @tagName(loaders[i]) else "unknown";
const entry_point_kind = @tagName(files_list.items(.entry_point_kind)[i]);
var flags = FileFlags{
.is_async = false,
.needs_exports_variable = false,
.needs_synthetic_default_export = false,
.wrap = "none",
};
if (i < meta_list.items(.flags).len) {
const meta_flags = meta_list.items(.flags)[i];
flags = .{
.is_async = meta_flags.is_async_or_has_async_dependency,
.needs_exports_variable = meta_flags.needs_exports_variable,
.needs_synthetic_default_export = meta_flags.needs_synthetic_default_export,
.wrap = @tagName(meta_flags.wrap),
};
}
const named_exports_count = if (i < ast_list.items(.named_exports).len)
ast_list.items(.named_exports)[i].count() else 0;
const named_imports_count = if (i < ast_list.items(.named_imports).len)
ast_list.items(.named_imports)[i].count() else 0;
const part_count = if (i < ast_list.items(.parts).len)
ast_list.items(.parts)[i].len else 0;
file_data_list[i] = .{
.index = i,
.path = path,
.loader = loader,
.source_length = if (i < sources.len) sources[i].contents.len else 0,
.entry_point_kind = entry_point_kind,
.part_count = part_count,
.parts = parts_data,
.named_exports_count = named_exports_count,
.named_imports_count = named_imports_count,
.flags = flags,
};
}
// Build symbol data
var by_source = try allocator.alloc(SourceSymbols, ctx.graph.symbols.symbols_for_source.len);
var total_symbols: usize = 0;
for (ctx.graph.symbols.symbols_for_source.slice(), 0..) |symbols, source_idx| {
total_symbols += symbols.len;
var symbol_infos = try allocator.alloc(SymbolInfo, symbols.len);
for (symbols.slice(), 0..) |symbol, j| {
symbol_infos[j] = .{
.inner_index = j,
.kind = @tagName(symbol.kind),
.original_name = symbol.original_name,
.link = if (symbol.link.isValid())
try std.fmt.allocPrint(allocator, "{}", .{symbol.link})
else null,
};
}
by_source[source_idx] = .{
.source_index = source_idx,
.symbol_count = symbols.len,
.symbols = symbol_infos,
};
}
const symbol_data = SymbolData{
.total_symbols = total_symbols,
.by_source = by_source,
};
// Build entry points
const entry_points = ctx.graph.entry_points.slice();
var entry_point_data = try allocator.alloc(EntryPointData, entry_points.len);
for (entry_points.items(.source_index), entry_points.items(.output_path), 0..) |source_idx, output_path, i| {
entry_point_data[i] = .{
.source_index = source_idx,
.output_path = output_path.slice(),
};
}
// Build imports and exports
const ast_named_exports = ast_list.items(.named_exports);
const ast_named_imports = ast_list.items(.named_imports);
const import_records_list = ast_list.items(.import_records);
var total_exports: usize = 0;
var total_imports: usize = 0;
var total_import_records: usize = 0;
// Count totals
for (ast_named_exports) |exports| {
total_exports += exports.count();
}
for (ast_named_imports) |imports| {
total_imports += imports.count();
}
for (import_records_list) |records| {
total_import_records += records.len;
}
// Collect all exports
var exports_list = try std.ArrayList(ExportInfo).initCapacity(allocator, @min(total_exports, 1000));
for (ast_named_exports, 0..) |exports, source_idx| {
if (exports.count() == 0) continue;
var iter = exports.iterator();
while (iter.next()) |entry| {
if (exports_list.items.len >= 1000) break; // Limit for performance
try exports_list.append(.{
.source = @intCast(source_idx),
.name = entry.key_ptr.*,
.ref = try std.fmt.allocPrint(allocator, "{}", .{entry.value_ptr.ref}),
});
}
if (exports_list.items.len >= 1000) break;
}
// Collect all imports
var imports_list = try std.ArrayList(ImportInfo).initCapacity(allocator, @min(total_import_records, 1000));
for (import_records_list, 0..) |records, source_idx| {
if (records.len == 0) continue;
for (records.slice()[0..@min(records.len, 100)]) |record| {
if (imports_list.items.len >= 1000) break; // Limit for performance
try imports_list.append(.{
.source = @intCast(source_idx),
.kind = @tagName(record.kind),
.path = record.path.text,
.target_source = if (record.source_index.isValid()) record.source_index.get() else null,
});
}
if (imports_list.items.len >= 1000) break;
}
const imports_exports = ImportsExports{
.total_exports = total_exports,
.total_imports = total_imports,
.total_import_records = total_import_records,
.exports = exports_list.items,
.imports = imports_list.items,
};
// Build chunks data
var chunks_data: ?[]ChunkData = null;
if (chunks) |chunk_list| {
chunks_data = try allocator.alloc(ChunkData, chunk_list.len);
for (chunk_list, 0..) |chunk, i| {
// Collect files in chunk
var files_in_chunk = try allocator.alloc(u32, chunk.files_with_parts_in_chunk.count());
var file_iter = chunk.files_with_parts_in_chunk.iterator();
var j: usize = 0;
while (file_iter.next()) |entry| : (j += 1) {
files_in_chunk[j] = entry.key_ptr.*;
}
chunks_data.?[i] = .{
.index = i,
.is_entry_point = chunk.entry_point.is_entry_point,
.source_index = chunk.entry_point.source_index,
.files_in_chunk = files_in_chunk,
.cross_chunk_import_count = chunk.cross_chunk_imports.len,
};
}
}
// Build dependency graph
const parts_lists = ast_list.items(.parts);
var edges = try std.ArrayList(GraphEdge).initCapacity(allocator, 1000);
for (parts_lists, 0..) |parts, source_idx| {
for (parts.slice(), 0..) |part, part_idx| {
for (part.dependencies.slice()) |dep| {
if (edges.items.len >= 1000) break; // Limit for performance
try edges.append(.{
.from = .{ .source = @intCast(source_idx), .part = @intCast(part_idx) },
.to = .{ .source = dep.source_index.get(), .part = dep.part_index },
});
}
if (edges.items.len >= 1000) break;
}
if (edges.items.len >= 1000) break;
}
const dependency_graph = DependencyGraph{
.edges = edges.items,
};
return GraphData{
.stage = stage,
.timestamp = timestamp,
.metadata = metadata,
.files = file_data_list,
.symbols = symbol_data,
.entry_points = entry_point_data,
.imports_and_exports = imports_exports,
.chunks = chunks_data,
.dependency_graph = dependency_graph,
};
}
fn generateVisualizerHTML(allocator: std.mem.Allocator, output_dir: []const u8, timestamp: i64) !void {
const html_content = @embedFile("./graph_visualizer.html");
const filename = try std.fmt.allocPrint(allocator, "{s}/visualizer_{d}.html", .{
output_dir,
timestamp,
});
const file = try std.fs.cwd().createFile(filename, .{});
defer file.close();
try file.writeAll(html_content);
debug("Visualizer HTML written to: {s}", .{filename});
}
};

View File

@@ -488,6 +488,16 @@ pub fn toAST(
value: Type,
) anyerror!js_ast.Expr {
const type_info: std.builtin.Type = @typeInfo(Type);
// Check if type has custom toExprForJSON method (only for structs, unions, and enums)
switch (type_info) {
.@"struct", .@"union", .@"enum" => {
if (comptime @hasDecl(Type, "toExprForJSON")) {
return try Type.toExprForJSON(&value, allocator);
}
},
else => {},
}
switch (type_info) {
.bool => {
@@ -536,7 +546,7 @@ pub fn toAST(
const exprs = try allocator.alloc(Expr, value.len);
for (exprs, 0..) |*ex, i| ex.* = try toAST(allocator, @TypeOf(value[i]), value[i]);
return Expr.init(js_ast.E.Array, js_ast.E.Array{ .items = exprs }, logger.Loc.Empty);
return Expr.init(js_ast.E.Array, js_ast.E.Array{ .items = .init(exprs) }, logger.Loc.Empty);
},
else => @compileError("Unable to stringify type '" ++ @typeName(T) ++ "'"),
},
@@ -548,9 +558,20 @@ pub fn toAST(
const exprs = try allocator.alloc(Expr, value.len);
for (exprs, 0..) |*ex, i| ex.* = try toAST(allocator, @TypeOf(value[i]), value[i]);
return Expr.init(js_ast.E.Array, js_ast.E.Array{ .items = exprs }, logger.Loc.Empty);
return Expr.init(js_ast.E.Array, js_ast.E.Array{ .items = .init(exprs) }, logger.Loc.Empty);
},
.@"struct" => |Struct| {
// Check if struct has a slice() method - treat it as an array
if (comptime @hasField(Type, "ptr") and @hasField(Type, "len")) {
// This looks like it might be array-like, check for slice method
if (comptime @hasDecl(Type, "slice")) {
const slice = value.slice();
const exprs = try allocator.alloc(Expr, slice.len);
for (exprs, 0..) |*ex, i| ex.* = try toAST(allocator, @TypeOf(slice[i]), slice[i]);
return Expr.init(js_ast.E.Array, js_ast.E.Array{ .items = .init(exprs) }, logger.Loc.Empty);
}
}
const fields: []const std.builtin.Type.StructField = Struct.fields;
var properties = try allocator.alloc(js_ast.G.Property, fields.len);
var property_i: usize = 0;