Skip to content

Instantly share code, notes, and snippets.

@usirin
Created March 15, 2026 03:40
Show Gist options
  • Select an option

  • Save usirin/4fa90066a88c9ff26a3f0b7dca74c541 to your computer and use it in GitHub Desktop.

Select an option

Save usirin/4fa90066a88c9ff26a3f0b7dca74c541 to your computer and use it in GitHub Desktop.
[RFC] rspack Module Graph Dump
title [RFC] rspack Module Graph Dump
date 2026-03-14
status draft
author Umut Sirin
tags
rfc
a11y
web-platform

rspack Module Graph Dump

Short Summary

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.

Assumed Knowledge

  • Metro is React Native's bundler. We use METRO_DUMP_GRAPH to dump the mobile module graph during clyde 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 the PluginManager and run for every build/watch cycle.

Technical Architecture

Metro's implementation

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's stats API

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.

The plugin implementation

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};
}

How it hooks in

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.

Usage

RSPACK_DUMP_GRAPH=/tmp/web-module-graph.json clyde app build
RSPACK_DUMP_GRAPH=/tmp/web-module-graph.json clyde app watch

Works in both build and watch modes because compiler.hooks.afterDone fires after every compilation.

Format comparison

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.

Validation

To verify output correctness:

  • Pick any module in the output, confirm its dependencies list matches the actual imports in that file
  • Pick any module, confirm each entry in inverseDependencies actually imports that module
  • Verify bidirectionality: if A lists B in dependencies, B should list A in inverseDependencies

Alternatives Considered

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.

Other Considerations

  • 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 afterDone hook 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment