plugin-discovery.ts 19 KB

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