Skip to content

Commit f33c32a

Browse files
mischnicRobPruzan
authored andcommitted
Turbopack: make stats.json useable (#81318)
Make the `.next/server/webpack-stats.json` file that was already getting generated with `TURBOPACK_STATS=1` actually usable with https://statoscope.tech/. Not the most efficient implementation, but definitely works for small apps. - Chunks have the parent/child connection (though I can't see that in the UI) - Modules have the `reasons` set, so the module graph traversal works now. Note that this all ignores scope hoisting though, so there are some modules missing right now in some views ![Bildschirmfoto 2025-07-04 um 22 44 42](https://github.com/user-attachments/assets/b51c93fd-fd6d-404c-b8db-26de4500beb2)
1 parent f8dcfbc commit f33c32a

File tree

6 files changed

+251
-45
lines changed

6 files changed

+251
-45
lines changed

crates/next-api/src/app.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1368,8 +1368,12 @@ impl AppEndpoint {
13681368
.should_create_webpack_stats()
13691369
.await?
13701370
{
1371-
let webpack_stats =
1372-
generate_webpack_stats(app_entry.original_name.clone(), &client_assets).await?;
1371+
let webpack_stats = generate_webpack_stats(
1372+
*module_graphs.base,
1373+
app_entry.original_name.clone(),
1374+
client_assets.iter().copied(),
1375+
)
1376+
.await?;
13731377
let stats_output = VirtualOutputAsset::new(
13741378
node_root.join(&format!(
13751379
"server/app{manifest_path_prefix}/webpack-stats.json",

crates/next-api/src/pages.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1392,8 +1392,12 @@ impl PageEndpoint {
13921392
.should_create_webpack_stats()
13931393
.await?
13941394
{
1395-
let webpack_stats =
1396-
generate_webpack_stats(original_name.to_owned(), &client_assets.await?).await?;
1395+
let webpack_stats = generate_webpack_stats(
1396+
self.client_module_graph(),
1397+
original_name.to_owned(),
1398+
client_assets.await?.iter().copied(),
1399+
)
1400+
.await?;
13971401
let stats_output = VirtualOutputAsset::new(
13981402
node_root.join(&format!(
13991403
"server/pages{manifest_path_prefix}/webpack-stats.json",

crates/next-api/src/webpack_stats.rs

Lines changed: 180 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,176 @@
11
use anyhow::Result;
2+
use rustc_hash::FxHashSet;
23
use serde::Serialize;
4+
use tracing::{Level, instrument};
35
use turbo_rcstr::RcStr;
4-
use turbo_tasks::{FxIndexMap, FxIndexSet, ResolvedVc, Vc};
6+
use turbo_tasks::{
7+
FxIndexMap, FxIndexSet, ResolvedVc, TryJoinIterExt, ValueToString, Vc, fxindexmap,
8+
};
59
use turbopack_browser::ecmascript::EcmascriptBrowserChunk;
610
use turbopack_core::{
7-
chunk::{Chunk, ChunkItem},
11+
chunk::{Chunk, ChunkItem, ChunkItemExt, ModuleId},
12+
module::Module,
13+
module_graph::ModuleGraph,
814
output::OutputAsset,
915
};
1016

11-
pub async fn generate_webpack_stats<'a, I>(
17+
#[instrument(level = Level::INFO, skip_all)]
18+
pub async fn generate_webpack_stats<I>(
19+
module_graph: Vc<ModuleGraph>,
1220
entry_name: RcStr,
1321
entry_assets: I,
1422
) -> Result<WebpackStats>
1523
where
16-
I: IntoIterator<Item = &'a ResolvedVc<Box<dyn OutputAsset>>>,
24+
I: IntoIterator<Item = ResolvedVc<Box<dyn OutputAsset>>>,
1725
{
1826
let mut assets = vec![];
1927
let mut chunks = vec![];
2028
let mut chunk_items: FxIndexMap<Vc<Box<dyn ChunkItem>>, FxIndexSet<RcStr>> =
2129
FxIndexMap::default();
22-
let mut modules = vec![];
30+
31+
let entry_assets = entry_assets.into_iter().collect::<Vec<_>>();
32+
33+
let (asset_parents, asset_children) = {
34+
let mut asset_children =
35+
FxIndexMap::with_capacity_and_hasher(entry_assets.len(), Default::default());
36+
let mut visited =
37+
FxHashSet::with_capacity_and_hasher(entry_assets.len(), Default::default());
38+
let mut queue = entry_assets.clone();
39+
while let Some(asset) = queue.pop() {
40+
if visited.insert(asset) {
41+
let references = asset.references().await?;
42+
asset_children.insert(asset, references.clone());
43+
queue.extend(references);
44+
}
45+
}
46+
47+
let mut asset_parents: FxIndexMap<_, Vec<_>> =
48+
FxIndexMap::with_capacity_and_hasher(entry_assets.len(), Default::default());
49+
for (&parent, children) in &asset_children {
50+
for child in children {
51+
asset_parents.entry(*child).or_default().push(parent);
52+
}
53+
}
54+
55+
(asset_parents, asset_children)
56+
};
57+
58+
let asset_reasons = {
59+
let module_graph = module_graph.await?;
60+
let mut edges = vec![];
61+
module_graph
62+
.traverse_all_edges_unordered(|(parent_node, r), current| {
63+
edges.push((
64+
parent_node.module,
65+
RcStr::from(format!("{}: {}", r.chunking_type, r.export)),
66+
current.module,
67+
));
68+
Ok(())
69+
})
70+
.await?;
71+
72+
let edges = edges
73+
.into_iter()
74+
.map(async |(parent, ty, child)| {
75+
let parent_path = parent.ident().path().await?.path.clone();
76+
Ok((
77+
child,
78+
WebpackStatsReason {
79+
module: parent_path.clone(),
80+
module_identifier: parent.ident().to_string().owned().await?,
81+
module_name: parent_path,
82+
ty,
83+
},
84+
))
85+
})
86+
.try_join()
87+
.await?;
88+
89+
let mut asset_reasons: FxIndexMap<_, Vec<_>> = FxIndexMap::default();
90+
for (child, reason) in edges {
91+
asset_reasons.entry(child).or_default().push(reason);
92+
}
93+
asset_reasons
94+
};
95+
2396
for asset in entry_assets {
24-
let path = normalize_client_path(&asset.path().await?.path);
97+
let path = RcStr::from(normalize_client_path(&asset.path().await?.path));
2598

2699
let Some(asset_len) = *asset.size_bytes().await? else {
27100
continue;
28101
};
29102

30-
if let Some(chunk) = ResolvedVc::try_downcast_type::<EcmascriptBrowserChunk>(*asset) {
31-
let chunk_ident = normalize_client_path(&chunk.path().await?.path);
103+
if let Some(chunk) = ResolvedVc::try_downcast_type::<EcmascriptBrowserChunk>(asset) {
32104
chunks.push(WebpackStatsChunk {
33105
size: asset_len,
34-
files: vec![chunk_ident.clone().into()],
35-
id: chunk_ident.clone().into(),
106+
files: vec![path.clone()],
107+
id: path.clone(),
108+
parents: if let Some(parents) = asset_parents.get(&asset) {
109+
parents
110+
.iter()
111+
.map(async |c| Ok(normalize_client_path(&c.path().await?.path).into()))
112+
.try_join()
113+
.await?
114+
} else {
115+
vec![]
116+
},
117+
children: if let Some(children) = asset_children.get(&asset) {
118+
children
119+
.iter()
120+
.map(async |c| Ok(normalize_client_path(&c.path().await?.path).into()))
121+
.try_join()
122+
.await?
123+
} else {
124+
vec![]
125+
},
36126
..Default::default()
37127
});
38128

39129
for item in chunk.chunk().chunk_items().await? {
40-
// let name =
41-
chunk_items
42-
.entry(**item)
43-
.or_default()
44-
.insert(chunk_ident.clone().into());
130+
chunk_items.entry(**item).or_default().insert(path.clone());
45131
}
46132
}
47133

48134
assets.push(WebpackStatsAsset {
49135
ty: "asset".into(),
50-
name: path.clone().into(),
51-
chunks: vec![path.into()],
136+
name: path.clone(),
137+
chunk_names: vec![path],
52138
size: asset_len,
53139
..Default::default()
54140
});
55141
}
56142

57-
for (chunk_item, chunks) in chunk_items {
58-
let size = *chunk_item
59-
.content_ident()
60-
.path()
61-
.await?
62-
.read()
63-
.len()
64-
.await?;
65-
let path = chunk_item.asset_ident().path().await?.path.clone();
66-
modules.push(WebpackStatsModule {
67-
name: path.clone(),
68-
id: path.clone(),
69-
chunks: chunks.into_iter().collect(),
70-
size,
71-
});
72-
}
143+
// TODO try to downcast modules to `EcmascriptMergedModule` to include the scope hoisted modules
144+
// as well
145+
146+
let modules = chunk_items
147+
.into_iter()
148+
.map(async |(chunk_item, chunks)| {
149+
let size = *chunk_item
150+
.content_ident()
151+
.path()
152+
.await?
153+
.read()
154+
.len()
155+
.await?;
156+
Ok(WebpackStatsModule {
157+
name: chunk_item.asset_ident().path().await?.path.clone(),
158+
id: chunk_item.id().owned().await?,
159+
identifier: chunk_item.asset_ident().to_string().owned().await?,
160+
chunks: chunks.into_iter().collect(),
161+
size,
162+
// TODO Find all incoming edges to this module
163+
reasons: asset_reasons
164+
.get(&chunk_item.module().to_resolved().await?)
165+
.cloned()
166+
.unwrap_or_default(),
167+
})
168+
})
169+
.try_join()
170+
.await?;
73171

74-
let mut entrypoints = FxIndexMap::default();
75-
entrypoints.insert(
76-
entry_name.clone(),
172+
let entrypoints: FxIndexMap<_, _> = fxindexmap!(
173+
entry_name.clone() =>
77174
WebpackStatsEntrypoint {
78175
name: entry_name.clone(),
79176
chunks: chunks.iter().map(|c| c.id.clone()).collect(),
@@ -83,7 +180,7 @@ where
83180
name: a.name.clone(),
84181
})
85182
.collect(),
86-
},
183+
}
87184
);
88185

89186
Ok(WebpackStats {
@@ -108,35 +205,80 @@ pub struct WebpackStatsAssetInfo {}
108205
pub struct WebpackStatsAsset {
109206
#[serde(rename = "type")]
110207
pub ty: RcStr,
208+
/// The `output` filename
111209
pub name: RcStr,
112210
pub info: WebpackStatsAssetInfo,
211+
/// The size of the file in bytes
113212
pub size: u64,
213+
/// Indicates whether or not the asset made it to the `output` directory
114214
pub emitted: bool,
215+
/// Indicates whether or not the asset was compared with the same file on the output file
216+
/// system
115217
pub compared_for_emit: bool,
116218
pub cached: bool,
219+
/// The chunks this asset contains
220+
pub chunk_names: Vec<RcStr>,
221+
/// The chunk IDs this asset contains
117222
pub chunks: Vec<RcStr>,
118223
}
119224

120225
#[derive(Serialize, Debug, Default)]
121226
#[serde(rename_all = "camelCase")]
122227
pub struct WebpackStatsChunk {
228+
/// Indicates whether or not the chunk went through Code Generation
123229
pub rendered: bool,
230+
/// Indicates whether this chunk is loaded on initial page load or lazily.
124231
pub initial: bool,
232+
/// Indicates whether or not the chunk contains the webpack runtime
125233
pub entry: bool,
126234
pub recorded: bool,
235+
/// The ID of this chunk
127236
pub id: RcStr,
237+
/// Chunk size in bytes
128238
pub size: u64,
129239
pub hash: RcStr,
240+
/// An array of filename strings that contain this chunk
130241
pub files: Vec<RcStr>,
242+
/// An list of chunk names contained within this chunk
243+
pub names: Vec<RcStr>,
244+
/// Parent chunk IDs
245+
pub parents: Vec<RcStr>,
246+
/// Child chunk IDs
247+
pub children: Vec<RcStr>,
131248
}
132249

133250
#[derive(Serialize, Debug)]
134251
#[serde(rename_all = "camelCase")]
135252
pub struct WebpackStatsModule {
253+
/// Path to the actual file
136254
pub name: RcStr,
137-
pub id: RcStr,
255+
/// The ID of the module
256+
pub id: ModuleId,
257+
/// A unique ID used internally
258+
pub identifier: RcStr,
138259
pub chunks: Vec<RcStr>,
139260
pub size: Option<u64>,
261+
pub reasons: Vec<WebpackStatsReason>,
262+
}
263+
264+
#[derive(Clone, Serialize, Debug)]
265+
#[serde(rename_all = "camelCase")]
266+
pub struct WebpackStatsReason {
267+
/// The [WebpackStatsModule::name]
268+
pub module: RcStr,
269+
// /// The [WebpackStatsModule::id]
270+
// pub module_id: ModuleId,
271+
/// The [WebpackStatsModule::identifier]
272+
pub module_identifier: RcStr,
273+
/// A more readable name for the module (used for "pretty-printing")
274+
pub module_name: RcStr,
275+
/// The [type of request](/api/module-methods) used
276+
#[serde(rename = "type")]
277+
pub ty: RcStr,
278+
// /// Raw string used for the `import` or `require` request
279+
// pub user_request: RcStr,
280+
// /// Lines of code that caused the module to be included
281+
// pub loc: RcStr
140282
}
141283

142284
#[derive(Serialize, Debug)]

packages/next/src/shared/lib/turbopack/manifest-loader.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ export class TurbopackManifestLoader {
339339
private mergeWebpackStats(statsFiles: Iterable<WebpackStats>): WebpackStats {
340340
const entrypoints: Record<string, StatsChunkGroup> = {}
341341
const assets: Map<string, StatsAsset> = new Map()
342-
const chunks: Map<string, StatsChunk> = new Map()
342+
const chunks: Map<string | number, StatsChunk> = new Map()
343343
const modules: Map<string | number, StatsModule> = new Map()
344344

345345
for (const statsFile of statsFiles) {
@@ -361,8 +361,8 @@ export class TurbopackManifestLoader {
361361

362362
if (statsFile.chunks) {
363363
for (const chunk of statsFile.chunks) {
364-
if (!chunks.has(chunk.name)) {
365-
chunks.set(chunk.name, chunk)
364+
if (!chunks.has(chunk.id!)) {
365+
chunks.set(chunk.id!, chunk)
366366
}
367367
}
368368
}
@@ -388,6 +388,7 @@ export class TurbopackManifestLoader {
388388
}
389389

390390
return {
391+
version: 'Turbopack',
391392
entrypoints,
392393
assets: [...assets.values()],
393394
chunks: [...chunks.values()],

0 commit comments

Comments
 (0)