plugin-discovery.ts 20 KB

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