plugin-discovery.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  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. importPath,
  208. ];
  209. if (importPath.endsWith('.js')) {
  210. possiblePaths.push(importPath.replace(/.js$/, '.ts'));
  211. }
  212. // Try each possible path
  213. let found = false;
  214. for (const possiblePath of possiblePaths) {
  215. const possiblePathExists = await fs.pathExists(possiblePath);
  216. if (possiblePathExists) {
  217. await processFile(possiblePath);
  218. found = true;
  219. break;
  220. }
  221. }
  222. // If none of the file paths worked, try the raw import path
  223. // (it might be a directory)
  224. const tryRawPath = !found && (await fs.pathExists(importPath));
  225. if (tryRawPath) {
  226. await processFile(importPath);
  227. }
  228. }
  229. } catch (e) {
  230. const message = e instanceof Error ? e.message : String(e);
  231. logger.error(`Failed to process ${filePath}: ${message}`);
  232. }
  233. }
  234. await processFile(vendureConfigPath);
  235. return {
  236. localPluginLocations,
  237. packageImports: Array.from(packageImportsSet),
  238. };
  239. }
  240. /**
  241. * If this is a class declaration that is decorated with the `VendurePlugin` decorator,
  242. * we want to return that class name, as we have found a local Vendure plugin.
  243. */
  244. function getVendurePluginClassName(node: ts.Node): string | undefined {
  245. if (ts.isClassDeclaration(node)) {
  246. const decorators = ts.canHaveDecorators(node) ? ts.getDecorators(node) : undefined;
  247. if (decorators?.length) {
  248. for (const decorator of decorators) {
  249. const decoratorName = getDecoratorName(decorator);
  250. if (decoratorName === 'VendurePlugin') {
  251. const className = node.name?.text;
  252. if (className) {
  253. return className;
  254. }
  255. }
  256. }
  257. }
  258. }
  259. }
  260. function getNpmPackageNameFromImport(importPath: string): string | undefined {
  261. if (!importPath.startsWith('.') && !importPath.startsWith('/')) {
  262. // Get the root package name (e.g. '@scope/package/subpath' -> '@scope/package')
  263. const packageName = importPath.startsWith('@')
  264. ? importPath.split('/').slice(0, 2).join('/')
  265. : importPath.split('/')[0];
  266. return packageName;
  267. }
  268. }
  269. function getPotentialPathAliasImportPaths(importPath: string, tsConfigInfo?: TsConfigPathsConfig) {
  270. const importsToFollow: string[] = [];
  271. if (!tsConfigInfo) {
  272. return importsToFollow;
  273. }
  274. for (const [alias, patterns] of Object.entries(tsConfigInfo.paths)) {
  275. const aliasPattern = alias.replace(/\*$/, '');
  276. if (importPath.startsWith(aliasPattern)) {
  277. const relativePart = importPath.slice(aliasPattern.length);
  278. // Try each pattern
  279. for (const pattern of patterns) {
  280. const resolvedPattern = pattern.replace(/\*$/, '');
  281. const resolvedPath = path.resolve(tsConfigInfo.baseUrl, resolvedPattern, relativePart);
  282. importsToFollow.push(resolvedPath);
  283. }
  284. }
  285. }
  286. return importsToFollow;
  287. }
  288. function getDecoratorName(decorator: ts.Decorator): string | undefined {
  289. if (ts.isCallExpression(decorator.expression)) {
  290. const expression = decorator.expression.expression;
  291. // Handle both direct usage and imported usage
  292. if (ts.isIdentifier(expression)) {
  293. return expression.text;
  294. }
  295. // Handle property access like `Decorators.VendurePlugin`
  296. if (ts.isPropertyAccessExpression(expression)) {
  297. return expression.name.text;
  298. }
  299. }
  300. return undefined;
  301. }
  302. interface FindPluginFilesOptions {
  303. outputPath: string;
  304. vendureConfigPath: string;
  305. logger: Logger;
  306. packageGlobs: string[];
  307. nodeModulesRoot?: string;
  308. }
  309. export async function findVendurePluginFiles({
  310. outputPath,
  311. vendureConfigPath,
  312. logger,
  313. nodeModulesRoot: providedNodeModulesRoot,
  314. packageGlobs,
  315. }: FindPluginFilesOptions): Promise<string[]> {
  316. let nodeModulesRoot = providedNodeModulesRoot;
  317. const readStart = Date.now();
  318. if (!nodeModulesRoot) {
  319. // If the node_modules root path has not been explicitly
  320. // specified, we will try to guess it by resolving the
  321. // `@vendure/core` package.
  322. try {
  323. const coreUrl = import.meta.resolve('@vendure/core');
  324. logger.debug(`Found core URL: ${coreUrl}`);
  325. const corePath = fileURLToPath(coreUrl);
  326. logger.debug(`Found core path: ${corePath}`);
  327. nodeModulesRoot = path.join(path.dirname(corePath), '..', '..');
  328. } catch (e) {
  329. logger.warn(`Failed to resolve @vendure/core: ${e instanceof Error ? e.message : String(e)}`);
  330. nodeModulesRoot = path.dirname(vendureConfigPath);
  331. }
  332. }
  333. const patterns = [
  334. // Local compiled plugins in temp dir
  335. path.join(outputPath, '**/*.js'),
  336. // Node modules patterns
  337. ...packageGlobs.map(pattern => path.join(nodeModulesRoot, pattern)),
  338. ];
  339. logger.debug(`Finding Vendure plugins using patterns: ${patterns.join('\n')}`);
  340. const globStart = Date.now();
  341. const files = await glob(patterns, {
  342. ignore: [
  343. // Standard test & doc files
  344. '**/node_modules/**/node_modules/**',
  345. '**/*.spec.js',
  346. '**/*.test.js',
  347. ],
  348. onlyFiles: true,
  349. absolute: true,
  350. followSymbolicLinks: false,
  351. stats: false,
  352. });
  353. logger.debug(`Glob found ${files.length} files in ${Date.now() - globStart}ms`);
  354. // Read files in larger parallel batches
  355. const batchSize = 100; // Increased batch size
  356. const potentialPluginFiles: string[] = [];
  357. for (let i = 0; i < files.length; i += batchSize) {
  358. const batch = files.slice(i, i + batchSize);
  359. const results = await Promise.all(
  360. batch.map(async file => {
  361. try {
  362. // Try reading just first 3000 bytes first - most imports are at the top
  363. const fileHandle = await open(file, 'r');
  364. try {
  365. const buffer = Buffer.alloc(3000);
  366. const { bytesRead } = await fileHandle.read(buffer, 0, 3000, 0);
  367. let content = buffer.toString('utf8', 0, bytesRead);
  368. // Quick check for common indicators
  369. if (content.includes('@vendure/core')) {
  370. return file;
  371. }
  372. // If we find a promising indicator but no definitive match,
  373. // read more of the file
  374. if (content.includes('@vendure') || content.includes('VendurePlugin')) {
  375. const largerBuffer = Buffer.alloc(5000);
  376. const { bytesRead: moreBytes } = await fileHandle.read(largerBuffer, 0, 5000, 0);
  377. content = largerBuffer.toString('utf8', 0, moreBytes);
  378. if (content.includes('@vendure/core')) {
  379. return file;
  380. }
  381. }
  382. } finally {
  383. await fileHandle.close();
  384. }
  385. } catch (e: any) {
  386. logger.warn(`Failed to read file ${file}: ${e instanceof Error ? e.message : String(e)}`);
  387. }
  388. return null;
  389. }),
  390. );
  391. const validResults = results.filter((f): f is string => f !== null);
  392. potentialPluginFiles.push(...validResults);
  393. }
  394. logger.info(
  395. `Found ${potentialPluginFiles.length} potential plugin files in ${Date.now() - readStart}ms ` +
  396. `(scanned ${files.length} files)`,
  397. );
  398. return potentialPluginFiles;
  399. }