plugin-discovery.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442
  1. import { parse } from 'acorn';
  2. import { simple as walkSimple } from 'acorn-walk';
  3. import glob from 'fast-glob';
  4. import fs from 'fs-extra';
  5. import { open } from 'fs/promises';
  6. import path from 'path';
  7. import * as ts from 'typescript';
  8. import { fileURLToPath } from 'url';
  9. import { Logger, PluginInfo, TransformTsConfigPathMappingsFn } from '../types.js';
  10. import { PackageScannerConfig } from './compiler.js';
  11. import { findTsConfigPaths, TsConfigPathsConfig } from './tsconfig-utils.js';
  12. export async function discoverPlugins({
  13. vendureConfigPath,
  14. transformTsConfigPathMappings,
  15. logger,
  16. outputPath,
  17. pluginPackageScanner,
  18. }: {
  19. vendureConfigPath: string;
  20. transformTsConfigPathMappings: TransformTsConfigPathMappingsFn;
  21. logger: Logger;
  22. outputPath: string;
  23. pluginPackageScanner?: PackageScannerConfig;
  24. }): Promise<PluginInfo[]> {
  25. const plugins: PluginInfo[] = [];
  26. // Analyze source files to find local plugins and package imports
  27. const { localPluginLocations, packageImports } = await analyzeSourceFiles(
  28. vendureConfigPath,
  29. logger,
  30. transformTsConfigPathMappings,
  31. );
  32. logger.debug(
  33. `[discoverPlugins] Found ${localPluginLocations.size} local plugins: ${JSON.stringify([...localPluginLocations.entries()], null, 2)}`,
  34. );
  35. logger.debug(
  36. `[discoverPlugins] Found ${packageImports.length} package imports: ${JSON.stringify(packageImports, null, 2)}`,
  37. );
  38. const filePaths = await findVendurePluginFiles({
  39. logger,
  40. nodeModulesRoot: pluginPackageScanner?.nodeModulesRoot,
  41. packageGlobs: packageImports.map(pkg => pkg + '/**/*.js'),
  42. outputPath,
  43. vendureConfigPath,
  44. });
  45. for (const filePath of filePaths) {
  46. const content = await fs.readFile(filePath, 'utf-8');
  47. logger.debug(`[discoverPlugins] Checking file ${filePath}`);
  48. // First check if this file imports from @vendure/core
  49. if (!content.includes('@vendure/core')) {
  50. continue;
  51. }
  52. try {
  53. const ast = parse(content, {
  54. ecmaVersion: 'latest',
  55. sourceType: 'module',
  56. });
  57. let hasVendurePlugin = false;
  58. let pluginName: string | undefined;
  59. let dashboardPath: string | undefined;
  60. // Walk the AST to find the plugin class and its decorator
  61. walkSimple(ast, {
  62. CallExpression(node: any) {
  63. // Look for __decorate calls
  64. const calleeName = node.callee.name;
  65. const nodeArgs = node.arguments;
  66. const isDecoratorWithArgs = calleeName === '__decorate' && nodeArgs.length >= 2;
  67. if (isDecoratorWithArgs) {
  68. // Check the decorators array (first argument)
  69. const decorators = nodeArgs[0];
  70. if (decorators.type === 'ArrayExpression') {
  71. for (const decorator of decorators.elements) {
  72. const props = getDecoratorObjectProps(decorator);
  73. for (const prop of props) {
  74. const isDashboardProd =
  75. prop.key.name === 'dashboard' && prop.value.type === 'Literal';
  76. if (isDashboardProd) {
  77. dashboardPath = prop.value.value;
  78. hasVendurePlugin = true;
  79. }
  80. }
  81. }
  82. }
  83. // Get the plugin class name (second argument)
  84. const targetClass = nodeArgs[1];
  85. if (targetClass.type === 'Identifier') {
  86. pluginName = targetClass.name;
  87. }
  88. }
  89. },
  90. });
  91. if (hasVendurePlugin && pluginName && dashboardPath) {
  92. logger.debug(`[discoverPlugins] Found plugin "${pluginName}" in file: ${filePath}`);
  93. // Keep the dashboard path relative to the plugin file
  94. const resolvedDashboardPath = dashboardPath.startsWith('.')
  95. ? dashboardPath // Keep the relative path as-is
  96. : './' + path.relative(path.dirname(filePath), dashboardPath); // Make absolute path relative
  97. // Check if this is a local plugin we found earlier
  98. const sourcePluginPath = localPluginLocations.get(pluginName);
  99. plugins.push({
  100. name: pluginName,
  101. pluginPath: filePath,
  102. dashboardEntryPath: resolvedDashboardPath,
  103. ...(sourcePluginPath && { sourcePluginPath }),
  104. });
  105. }
  106. } catch (e) {
  107. logger.error(`Failed to parse ${filePath}: ${e instanceof Error ? e.message : String(e)}`);
  108. }
  109. }
  110. return plugins;
  111. }
  112. function getDecoratorObjectProps(decorator: any): any[] {
  113. if (
  114. decorator.type === 'CallExpression' &&
  115. decorator.arguments.length === 1 &&
  116. decorator.arguments[0].type === 'ObjectExpression'
  117. ) {
  118. // Look for the dashboard property in the decorator config
  119. return decorator.arguments[0].properties ?? [];
  120. }
  121. return [];
  122. }
  123. /**
  124. * Analyzes TypeScript source files starting from the config file to discover:
  125. * 1. Local Vendure plugins
  126. * 2. All non-local package imports that could contain plugins
  127. */
  128. export async function analyzeSourceFiles(
  129. vendureConfigPath: string,
  130. logger: Logger,
  131. transformTsConfigPathMappings: TransformTsConfigPathMappingsFn,
  132. ): Promise<{
  133. localPluginLocations: Map<string, string>;
  134. packageImports: string[];
  135. }> {
  136. const localPluginLocations = new Map<string, string>();
  137. const visitedFiles = new Set<string>();
  138. const packageImportsSet = new Set<string>();
  139. // Get tsconfig paths for resolving aliases
  140. const tsConfigInfo = await findTsConfigPaths(
  141. vendureConfigPath,
  142. logger,
  143. 'compiling',
  144. transformTsConfigPathMappings,
  145. );
  146. async function processFile(filePath: string) {
  147. if (visitedFiles.has(filePath)) {
  148. return;
  149. }
  150. visitedFiles.add(filePath);
  151. try {
  152. // First check if this is a directory
  153. const stat = await fs.stat(filePath);
  154. if (stat.isDirectory()) {
  155. // If it's a directory, try to find the plugin file
  156. const indexFilePath = path.join(filePath, 'index.ts');
  157. if (await fs.pathExists(indexFilePath)) {
  158. await processFile(indexFilePath);
  159. }
  160. return;
  161. }
  162. const content = await fs.readFile(filePath, 'utf-8');
  163. const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
  164. // Track imports to follow
  165. const importsToFollow: string[] = [];
  166. function visit(node: ts.Node) {
  167. // Look for VendurePlugin decorator
  168. const vendurePluginClassName = getVendurePluginClassName(node);
  169. if (vendurePluginClassName) {
  170. localPluginLocations.set(vendurePluginClassName, filePath);
  171. logger.debug(`Found plugin "${vendurePluginClassName}" at ${filePath}`);
  172. }
  173. // Handle both imports and exports
  174. const isImportOrExport = ts.isImportDeclaration(node) || ts.isExportDeclaration(node);
  175. if (isImportOrExport) {
  176. const moduleSpecifier = node.moduleSpecifier;
  177. if (moduleSpecifier && ts.isStringLiteral(moduleSpecifier)) {
  178. const importPath = moduleSpecifier.text;
  179. // Track non-local imports (packages)
  180. const npmPackageName = getNpmPackageNameFromImport(importPath);
  181. if (npmPackageName) {
  182. packageImportsSet.add(npmPackageName);
  183. }
  184. // Handle path aliases and local imports
  185. const pathAliasImports = getPotentialPathAliasImportPaths(importPath, tsConfigInfo);
  186. if (pathAliasImports.length) {
  187. importsToFollow.push(...pathAliasImports);
  188. }
  189. // Also handle local imports
  190. if (importPath.startsWith('.')) {
  191. const resolvedPath = path.resolve(path.dirname(filePath), importPath);
  192. importsToFollow.push(resolvedPath);
  193. }
  194. }
  195. }
  196. ts.forEachChild(node, visit);
  197. }
  198. visit(sourceFile);
  199. // Follow imports
  200. for (const importPath of importsToFollow) {
  201. // Try all possible file paths
  202. const possiblePaths = [
  203. importPath + '.ts',
  204. importPath + '.js',
  205. path.join(importPath, 'index.ts'),
  206. path.join(importPath, 'index.js'),
  207. ];
  208. // Try each possible path
  209. let found = false;
  210. for (const possiblePath of possiblePaths) {
  211. const possiblePathExists = await fs.pathExists(possiblePath);
  212. if (possiblePathExists) {
  213. await processFile(possiblePath);
  214. found = true;
  215. break;
  216. }
  217. }
  218. // If none of the file paths worked, try the raw import path
  219. // (it might be a directory)
  220. const tryRawPath = !found && (await fs.pathExists(importPath));
  221. if (tryRawPath) {
  222. await processFile(importPath);
  223. }
  224. }
  225. } catch (e) {
  226. const message = e instanceof Error ? e.message : String(e);
  227. logger.error(`Failed to process ${filePath}: ${message}`);
  228. }
  229. }
  230. await processFile(vendureConfigPath);
  231. return {
  232. localPluginLocations,
  233. packageImports: Array.from(packageImportsSet),
  234. };
  235. }
  236. /**
  237. * If this is a class declaration that is decorated with the `VendurePlugin` decorator,
  238. * we want to return that class name, as we have found a local Vendure plugin.
  239. */
  240. function getVendurePluginClassName(node: ts.Node): string | undefined {
  241. if (ts.isClassDeclaration(node)) {
  242. const decorators = ts.canHaveDecorators(node) ? ts.getDecorators(node) : undefined;
  243. if (decorators?.length) {
  244. for (const decorator of decorators) {
  245. const decoratorName = getDecoratorName(decorator);
  246. if (decoratorName === 'VendurePlugin') {
  247. const className = node.name?.text;
  248. if (className) {
  249. return className;
  250. }
  251. }
  252. }
  253. }
  254. }
  255. }
  256. function getNpmPackageNameFromImport(importPath: string): string | undefined {
  257. if (!importPath.startsWith('.') && !importPath.startsWith('/')) {
  258. // Get the root package name (e.g. '@scope/package/subpath' -> '@scope/package')
  259. const packageName = importPath.startsWith('@')
  260. ? importPath.split('/').slice(0, 2).join('/')
  261. : importPath.split('/')[0];
  262. return packageName;
  263. }
  264. }
  265. function getPotentialPathAliasImportPaths(importPath: string, tsConfigInfo?: TsConfigPathsConfig) {
  266. const importsToFollow: string[] = [];
  267. if (!tsConfigInfo) {
  268. return importsToFollow;
  269. }
  270. for (const [alias, patterns] of Object.entries(tsConfigInfo.paths)) {
  271. const aliasPattern = alias.replace(/\*$/, '');
  272. if (importPath.startsWith(aliasPattern)) {
  273. const relativePart = importPath.slice(aliasPattern.length);
  274. // Try each pattern
  275. for (const pattern of patterns) {
  276. const resolvedPattern = pattern.replace(/\*$/, '');
  277. const resolvedPath = path.resolve(tsConfigInfo.baseUrl, resolvedPattern, relativePart);
  278. importsToFollow.push(resolvedPath);
  279. }
  280. }
  281. }
  282. return importsToFollow;
  283. }
  284. function getDecoratorName(decorator: ts.Decorator): string | undefined {
  285. if (ts.isCallExpression(decorator.expression)) {
  286. const expression = decorator.expression.expression;
  287. // Handle both direct usage and imported usage
  288. if (ts.isIdentifier(expression)) {
  289. return expression.text;
  290. }
  291. // Handle property access like `Decorators.VendurePlugin`
  292. if (ts.isPropertyAccessExpression(expression)) {
  293. return expression.name.text;
  294. }
  295. }
  296. return undefined;
  297. }
  298. interface FindPluginFilesOptions {
  299. outputPath: string;
  300. vendureConfigPath: string;
  301. logger: Logger;
  302. packageGlobs: string[];
  303. nodeModulesRoot?: string;
  304. }
  305. export async function findVendurePluginFiles({
  306. outputPath,
  307. vendureConfigPath,
  308. logger,
  309. nodeModulesRoot: providedNodeModulesRoot,
  310. packageGlobs,
  311. }: FindPluginFilesOptions): Promise<string[]> {
  312. let nodeModulesRoot = providedNodeModulesRoot;
  313. const readStart = Date.now();
  314. if (!nodeModulesRoot) {
  315. // If the node_modules root path has not been explicitly
  316. // specified, we will try to guess it by resolving the
  317. // `@vendure/core` package.
  318. try {
  319. const coreUrl = import.meta.resolve('@vendure/core');
  320. logger.debug(`Found core URL: ${coreUrl}`);
  321. const corePath = fileURLToPath(coreUrl);
  322. logger.debug(`Found core path: ${corePath}`);
  323. nodeModulesRoot = path.join(path.dirname(corePath), '..', '..');
  324. } catch (e) {
  325. logger.warn(`Failed to resolve @vendure/core: ${e instanceof Error ? e.message : String(e)}`);
  326. nodeModulesRoot = path.dirname(vendureConfigPath);
  327. }
  328. }
  329. const patterns = [
  330. // Local compiled plugins in temp dir
  331. path.join(outputPath, '**/*.js'),
  332. // Node modules patterns
  333. ...packageGlobs.map(pattern => path.join(nodeModulesRoot, pattern)),
  334. ];
  335. logger.debug(`Finding Vendure plugins using patterns: ${patterns.join('\n')}`);
  336. const globStart = Date.now();
  337. const files = await glob(patterns, {
  338. ignore: [
  339. // Standard test & doc files
  340. '**/node_modules/**/node_modules/**',
  341. '**/*.spec.js',
  342. '**/*.test.js',
  343. ],
  344. onlyFiles: true,
  345. absolute: true,
  346. followSymbolicLinks: false,
  347. stats: false,
  348. });
  349. logger.debug(`Glob found ${files.length} files in ${Date.now() - globStart}ms`);
  350. // Read files in larger parallel batches
  351. const batchSize = 100; // Increased batch size
  352. const potentialPluginFiles: string[] = [];
  353. for (let i = 0; i < files.length; i += batchSize) {
  354. const batch = files.slice(i, i + batchSize);
  355. const results = await Promise.all(
  356. batch.map(async file => {
  357. try {
  358. // Try reading just first 3000 bytes first - most imports are at the top
  359. const fileHandle = await open(file, 'r');
  360. try {
  361. const buffer = Buffer.alloc(3000);
  362. const { bytesRead } = await fileHandle.read(buffer, 0, 3000, 0);
  363. let content = buffer.toString('utf8', 0, bytesRead);
  364. // Quick check for common indicators
  365. if (content.includes('@vendure/core')) {
  366. return file;
  367. }
  368. // If we find a promising indicator but no definitive match,
  369. // read more of the file
  370. if (content.includes('@vendure') || content.includes('VendurePlugin')) {
  371. const largerBuffer = Buffer.alloc(5000);
  372. const { bytesRead: moreBytes } = await fileHandle.read(largerBuffer, 0, 5000, 0);
  373. content = largerBuffer.toString('utf8', 0, moreBytes);
  374. if (content.includes('@vendure/core')) {
  375. return file;
  376. }
  377. }
  378. } finally {
  379. await fileHandle.close();
  380. }
  381. } catch (e: any) {
  382. logger.warn(`Failed to read file ${file}: ${e instanceof Error ? e.message : String(e)}`);
  383. }
  384. return null;
  385. }),
  386. );
  387. const validResults = results.filter((f): f is string => f !== null);
  388. potentialPluginFiles.push(...validResults);
  389. }
  390. logger.info(
  391. `Found ${potentialPluginFiles.length} potential plugin files in ${Date.now() - readStart}ms ` +
  392. `(scanned ${files.length} files)`,
  393. );
  394. return potentialPluginFiles;
  395. }