| title | [RFC] rspack Module Graph Dump | |||
|---|---|---|---|---|
| date | 2026-03-14 | |||
| status | draft | |||
| author | Umut Sirin | |||
| tags |
|
Add a RSPACK_DUMP_GRAPH env var to the web build that writes the full module dependency graph to a JSON file. Output format matches Metro's METRO_DUMP_GRAPH so downstream tooling (screen census, button migration scripts, dependency analysis) works across both platforms without format-specific adapters.
This is already implemented and merged as a builtin plugin in the web-bundler. This RFC documents the design for reference.
- Metro is React Native's bundler. We use
METRO_DUMP_GRAPHto dump the mobile module graph duringclyde mobile watch. - rspack is our web bundler (webpack-compatible). It exposes module stats via
stats.toJson(). - The web-bundler (
@discordapp/web-bundler) has a plugin system where builtins are registered in thePluginManagerand run for every build/watch cycle.
In discord_app/metro.config.js, the serializer hook runs after every bundle and writes the graph when the env var is set:
serializer: {
experimentalSerializerHook: (graph) => {
if (process.env.METRO_DUMP_GRAPH) {
const fs = require('node:fs');
const modules = {};
for (const [path, module] of graph.dependencies) {
modules[path] = {
dependencies: Array.from(module.dependencies.values())
.map((dep) => dep.absolutePath)
.filter(Boolean),
inverseDependencies: Array.from(module.inverseDependencies),
};
}
fs.writeFileSync(
process.env.METRO_DUMP_GRAPH,
JSON.stringify({entryPoints: Array.from(graph.entryPoints), modules}, null, 2),
);
console.log(`[metro] Wrote ${graph.dependencies.size} modules to ${process.env.METRO_DUMP_GRAPH}`);
}
},
},Metro's output format:
{
"entryPoints": ["/absolute/path/to/index.js"],
"modules": {
"/absolute/path/to/Foo.tsx": {
"dependencies": ["/absolute/path/to/Bar.tsx", "/absolute/path/to/utils.ts"],
"inverseDependencies": ["/absolute/path/to/App.tsx"]
}
}
}Key properties of this format:
- Module keys are absolute file paths
dependencies= files this module imports (forward edges)inverseDependencies= files that import this module (reverse edges)- Both directions are present per module, making traversal in either direction O(1)
rspack exposes module information via stats.toJson(). The relevant fields per module:
interface StatsModule {
identifier: string; // Internal rspack ID (includes loader prefix, e.g. "builtin:swc-loader|/path/to/file.tsx")
nameForCondition: string; // Clean file path without loader prefix
reasons: Array<{
moduleIdentifier: string; // identifier of the importing module
}>;
}The key difference: Metro gives both directions directly. rspack only gives reasons (inverse dependencies). Forward dependencies must be derived by inverting the reasons graph.
Located at discord_common/js/packages/web-bundler/src/plugins/builtins/plugin-graph-dump.tsx:
import fs from 'node:fs';
import path from 'node:path';
import type {Compiler, StatsModule} from '@rspack/core';
import {definePlugin} from '#~/lib/config-manager';
const PLUGIN_NAME = 'DiscordGraphDumpPlugin';
export function pluginGraphDump() {
return definePlugin({
name: 'discord:graph-dump',
setup(api) {
const outputPath = process.env.RSPACK_DUMP_GRAPH;
if (outputPath == null) return;
api.modifyChain((chain) => {
chain.plugin(PLUGIN_NAME).use({
apply(compiler: Compiler) {
compiler.hooks.afterDone.tap(PLUGIN_NAME, (stats) => {
const json = stats.toJson({modules: true, reasons: true});
const graph = transformToMetroFormat(json.modules ?? []);
fs.mkdirSync(path.dirname(outputPath), {recursive: true});
fs.writeFileSync(outputPath, JSON.stringify(graph, null, 2));
console.log(`[rspack] Wrote ${Object.keys(graph.modules).length} modules to ${outputPath}`);
});
},
});
});
},
});
}
function transformToMetroFormat(modules: StatsModule[]) {
const graph: Record<string, {dependencies: string[]; inverseDependencies: string[]}> = {};
// Build identifier -> clean path mapping. rspack reasons use the full
// identifier (with loader prefix) while we key by nameForCondition.
const identifierToPath = new Map<string, string>();
for (const mod of modules) {
const filePath = mod.nameForCondition ?? mod.identifier;
if (filePath == null) continue;
if (mod.identifier != null) {
identifierToPath.set(mod.identifier, filePath);
}
graph[filePath] = {inverseDependencies: [], dependencies: []};
}
// Populate inverseDependencies using the identifier mapping
for (const mod of modules) {
const filePath = mod.nameForCondition ?? mod.identifier;
if (filePath == null) continue;
for (const reason of mod.reasons ?? []) {
const resolvedPath = reason.moduleIdentifier != null
? identifierToPath.get(reason.moduleIdentifier)
: undefined;
if (resolvedPath != null) {
graph[filePath].inverseDependencies.push(resolvedPath);
}
}
}
// Invert: build forward dependencies from inverse
for (const [filePath, info] of Object.entries(graph)) {
for (const dep of info.inverseDependencies) {
if (graph[dep] != null) {
graph[dep].dependencies.push(filePath);
}
}
}
return {modules: graph};
}The plugin is registered as a builtin in discord_common/js/packages/web-bundler/src/lib/plugin-manager.tsx:
this.builtins = [
// ... other builtins
pluginGraphDump(),
// ...
];This means it runs for every project that uses the web-bundler (app-web, developer portal, etc.) without any config changes. When RSPACK_DUMP_GRAPH is unset, the plugin's setup function returns early and adds nothing to the chain.
RSPACK_DUMP_GRAPH=/tmp/web-module-graph.json clyde app build
RSPACK_DUMP_GRAPH=/tmp/web-module-graph.json clyde app watchWorks in both build and watch modes because compiler.hooks.afterDone fires after every compilation.
| Property | Metro | rspack (raw stats) | rspack (after transform) |
|---|---|---|---|
| Module key | absolute path | nameForCondition (absolute path) |
absolute path |
| Forward deps | dependencies (direct) |
not provided | derived from reasons |
| Reverse deps | inverseDependencies (direct) |
reasons[].moduleIdentifier |
mapped via identifier lookup |
| Entry points | entryPoints array |
not included | not included (could add) |
The only gap is entryPoints. Metro includes the entry point paths in the top-level output. The rspack plugin omits this because downstream tooling (screen census, migration scripts) only uses the module graph, not entry points. If needed, entry points can be added from json.entrypoints.
To verify output correctness:
- Pick any module in the output, confirm its
dependencieslist matches the actual imports in that file - Pick any module, confirm each entry in
inverseDependenciesactually imports that module - Verify bidirectionality: if A lists B in
dependencies, B should list A ininverseDependencies
Use rspack stats directly without transform. Downstream tooling already consumes Metro format. Asking every consumer to handle two formats adds complexity for no benefit. The transform is ~40 lines and runs once per build.
Add a CLI flag instead of env var. Metro uses an env var (METRO_DUMP_GRAPH), and matching that convention means scripts that already set one env var for mobile can set another for web with minimal changes. A CLI flag would also require threading the option through the web-bundler's params/options system.
Write to a fixed location. The env var approach lets the caller control the output path, which is important for CI (temp dirs), local dev (Desktop), and scripts that consume the output immediately.
- The
stats.toJson()call with{modules: true, reasons: true}is not free. On a full app-web build it adds a few seconds. This only runs when the env var is set, so it has zero cost in normal builds. - The
afterDonehook fires after the stats are finalized, so the graph is always consistent with what was actually bundled. - In watch mode, the graph is re-dumped on every recompilation. This is intentional, as the graph changes when files are added/removed.