next.config.utils.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. // workaround by https://github.com/martpie/next-transpile-modules/issues/143#issuecomment-817467144
  2. import fs from 'node:fs';
  3. import path from 'node:path';
  4. const nodeModulesPaths = [
  5. path.resolve(__dirname, '../../node_modules'),
  6. path.resolve(__dirname, '../../../../node_modules'),
  7. ];
  8. interface Opts {
  9. ignorePackageNames: string[];
  10. }
  11. const defaultOpts: Opts = { ignorePackageNames: [] };
  12. export const listScopedPackages = (
  13. scopes: string[],
  14. opts: Opts = defaultOpts,
  15. ): string[] => {
  16. const scopedPackages: string[] = [];
  17. nodeModulesPaths.forEach((nodeModulesPath) => {
  18. fs.readdirSync(nodeModulesPath)
  19. .filter((name) => scopes.includes(name))
  20. .forEach((scope) => {
  21. fs.readdirSync(path.resolve(nodeModulesPath, scope))
  22. .filter((name) => !name.startsWith('.'))
  23. .forEach((folderName) => {
  24. const packageJsonPath = path.resolve(
  25. nodeModulesPath,
  26. scope,
  27. folderName,
  28. 'package.json',
  29. );
  30. if (fs.existsSync(packageJsonPath)) {
  31. const { name } = JSON.parse(
  32. fs.readFileSync(packageJsonPath, 'utf-8'),
  33. ) as { name: string };
  34. if (!opts.ignorePackageNames.includes(name)) {
  35. scopedPackages.push(name);
  36. }
  37. }
  38. });
  39. });
  40. });
  41. return scopedPackages;
  42. };
  43. type WebpackCompiler = {
  44. outputPath: string;
  45. hooks: {
  46. done: {
  47. tap(name: string, callback: (stats: any) => void): void;
  48. };
  49. };
  50. };
  51. /**
  52. * Webpack plugin that logs eager (initial) vs lazy (async-only) module counts.
  53. * Attach to client-side dev builds only.
  54. */
  55. export const createChunkModuleStatsPlugin = () => ({
  56. apply(compiler: WebpackCompiler) {
  57. compiler.hooks.done.tap('ChunkModuleStatsPlugin', (stats) => {
  58. const { compilation } = stats;
  59. const initialModuleIds = new Set<string>();
  60. const asyncModuleIds = new Set<string>();
  61. for (const chunk of compilation.chunks) {
  62. const target = chunk.canBeInitial() ? initialModuleIds : asyncModuleIds;
  63. for (const module of compilation.chunkGraph.getChunkModulesIterable(
  64. chunk,
  65. )) {
  66. target.add(module.identifier());
  67. }
  68. }
  69. // Modules that appear ONLY in async chunks
  70. const asyncOnlyCount = [...asyncModuleIds].filter(
  71. (id) => !initialModuleIds.has(id),
  72. ).length;
  73. // biome-ignore lint/suspicious/noConsole: Dev-only module stats for compilation analysis
  74. console.log(
  75. `[ChunkModuleStats] initial: ${initialModuleIds.size}, async-only: ${asyncOnlyCount}, total: ${compilation.modules.size}`,
  76. );
  77. // Dump module details to file for analysis (only for large compilations)
  78. if (
  79. initialModuleIds.size > 500 &&
  80. process.env.DUMP_INITIAL_MODULES === '1'
  81. ) {
  82. const asyncOnlyIds = [...asyncModuleIds].filter(
  83. (id) => !initialModuleIds.has(id),
  84. );
  85. const analyzeModuleSet = (
  86. moduleIds: Set<string> | string[],
  87. title: string,
  88. filename: string,
  89. ): void => {
  90. const packageCounts: Record<string, number> = {};
  91. const appModules: string[] = [];
  92. for (const rawId of moduleIds) {
  93. // Strip webpack loader prefixes (e.g., "source-map-loader!/path/to/file" → "/path/to/file")
  94. const id = rawId.includes('!')
  95. ? rawId.slice(rawId.lastIndexOf('!') + 1)
  96. : rawId;
  97. const nmIdx = id.lastIndexOf('node_modules/');
  98. if (nmIdx !== -1) {
  99. const rest = id.slice(nmIdx + 'node_modules/'.length);
  100. const pkg = rest.startsWith('@')
  101. ? rest.split('/').slice(0, 2).join('/')
  102. : rest.split('/')[0];
  103. packageCounts[pkg] = (packageCounts[pkg] || 0) + 1;
  104. } else {
  105. appModules.push(id);
  106. }
  107. }
  108. const sorted = Object.entries(packageCounts).sort(
  109. (a, b) => b[1] - a[1],
  110. );
  111. const lines = [`# ${title}`, ''];
  112. const totalCount = Array.isArray(moduleIds)
  113. ? moduleIds.length
  114. : moduleIds.size;
  115. lines.push(`Total modules: ${totalCount}`);
  116. lines.push(`App modules (non-node_modules): ${appModules.length}`);
  117. lines.push(`node_modules packages: ${sorted.length}`);
  118. lines.push('');
  119. lines.push('## Top Packages by Module Count');
  120. lines.push('| # | Package | Modules |');
  121. lines.push('|---|---------|---------|');
  122. for (let i = 0; i < sorted.length; i++) {
  123. const [pkg, count] = sorted[i];
  124. lines.push(`| ${i + 1} | ${pkg} | ${count} |`);
  125. }
  126. lines.push('');
  127. lines.push('## App Modules (first 200)');
  128. for (const m of appModules.slice(0, 200)) {
  129. lines.push(`- ${m}`);
  130. }
  131. const outPath = path.resolve(compiler.outputPath, '..', filename);
  132. fs.writeFileSync(outPath, lines.join('\n'));
  133. // biome-ignore lint/suspicious/noConsole: Dev-only module stats dump path
  134. console.log(
  135. `[ChunkModuleStats] Dumped ${title.toLowerCase()} to ${outPath}`,
  136. );
  137. };
  138. analyzeModuleSet(
  139. initialModuleIds,
  140. 'Initial Chunk Module Analysis',
  141. 'initial-modules-analysis.md',
  142. );
  143. analyzeModuleSet(
  144. asyncOnlyIds,
  145. 'Async-Only Chunk Module Analysis',
  146. 'async-modules-analysis.md',
  147. );
  148. }
  149. });
  150. },
  151. });
  152. export const listPrefixedPackages = (
  153. prefixes: string[],
  154. opts: Opts = defaultOpts,
  155. ): string[] => {
  156. const prefixedPackages: string[] = [];
  157. nodeModulesPaths.forEach((nodeModulesPath) => {
  158. fs.readdirSync(nodeModulesPath)
  159. .filter((name) => prefixes.some((prefix) => name.startsWith(prefix)))
  160. .filter((name) => !name.startsWith('.'))
  161. .forEach((folderName) => {
  162. const packageJsonPath = path.resolve(
  163. nodeModulesPath,
  164. folderName,
  165. 'package.json',
  166. );
  167. if (fs.existsSync(packageJsonPath)) {
  168. const { name } = JSON.parse(
  169. fs.readFileSync(packageJsonPath, 'utf-8'),
  170. ) as { name: string };
  171. if (!opts.ignorePackageNames.includes(name)) {
  172. prefixedPackages.push(name);
  173. }
  174. }
  175. });
  176. });
  177. return prefixedPackages;
  178. };