config-loader.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555
  1. import { VendureConfig } from '@vendure/core';
  2. import fs from 'fs-extra';
  3. import path from 'path';
  4. import tsConfigPaths from 'tsconfig-paths';
  5. import * as ts from 'typescript';
  6. import { pathToFileURL } from 'url';
  7. import { findConfigExport, getPluginInfo } from './ast-utils.js';
  8. type Logger = {
  9. info: (message: string) => void;
  10. warn: (message: string) => void;
  11. debug: (message: string) => void;
  12. };
  13. export type PluginInfo = {
  14. name: string;
  15. pluginPath: string;
  16. dashboardEntryPath: string | undefined;
  17. };
  18. const defaultLogger: Logger = {
  19. info: (message: string) => {
  20. /* noop */
  21. },
  22. warn: (message: string) => {
  23. /* noop */
  24. },
  25. debug: (message: string) => {
  26. /* noop */
  27. },
  28. };
  29. /**
  30. * @description
  31. * The PathAdapter interface allows customization of how paths are handled
  32. * when compiling the Vendure config and its imports.
  33. *
  34. * This is particularly useful in complex project structures, such as monorepos,
  35. * where the Vendure config file may not be in the root directory,
  36. * or when you need to transform TypeScript path mappings.
  37. */
  38. export interface PathAdapter {
  39. /**
  40. * @description
  41. * A function to determine the path to the compiled Vendure config file. The default implementation
  42. * simple joins the output directory with the config file name:
  43. *
  44. * ```ts
  45. * return path.join(outputPath, configFileName)
  46. * ```
  47. *
  48. * However, in some cases with more complex project structures, you may need to
  49. * provide a custom implementation to ensure the compiled config file is
  50. * correctly located.
  51. *
  52. * @example
  53. * ```ts
  54. * getCompiledConfigPath: ({ inputRootDir, outputPath, configFileName }) => {
  55. * const projectName = inputRootDir.split('/libs/')[1].split('/')[0];
  56. * const pathAfterProject = inputRootDir.split(`/libs/${projectName}`)[1];
  57. * const compiledConfigFilePath = `${outputPath}/${projectName}${pathAfterProject}`;
  58. * return path.join(compiledConfigFilePath, configFileName);
  59. * },
  60. * ```
  61. */
  62. getCompiledConfigPath?: (params: {
  63. inputRootDir: string;
  64. outputPath: string;
  65. configFileName: string;
  66. }) => string;
  67. /**
  68. * If your project makes use of the TypeScript `paths` configuration, the compiler will
  69. * attempt to use these paths when compiling the Vendure config and its imports.
  70. *
  71. * In certain cases, you may need to transform these paths before they are used. For instance,
  72. * if your project is a monorepo and the paths are defined relative to the root of the monorepo,
  73. * you may need to adjust them to be relative to the output directory where the compiled files are located.
  74. *
  75. * @example
  76. * ```ts
  77. * transformTsConfigPathMappings: ({ phase, patterns }) => {
  78. * // "loading" phase is when the compiled Vendure code is being loaded by
  79. * // the plugin, in order to introspect the configuration of your app.
  80. * if (phase === 'loading') {
  81. * return patterns.map((p) =>
  82. * p.replace('libs/', '').replace(/.ts$/, '.js'),
  83. * );
  84. * }
  85. * return patterns;
  86. * },
  87. * ```
  88. * @param params
  89. */
  90. transformTsConfigPathMappings?: (params: {
  91. phase: 'compiling' | 'loading';
  92. alias: string;
  93. patterns: string[];
  94. }) => string[];
  95. }
  96. const defaultPathAdapter: Required<PathAdapter> = {
  97. getCompiledConfigPath: ({ outputPath, configFileName }) => path.join(outputPath, configFileName),
  98. transformTsConfigPathMappings: ({ patterns }) => patterns,
  99. };
  100. export interface ConfigLoaderOptions {
  101. vendureConfigPath: string;
  102. tempDir: string;
  103. pathAdapter?: PathAdapter;
  104. vendureConfigExport?: string;
  105. logger?: Logger;
  106. reportCompilationErrors?: boolean;
  107. }
  108. export interface LoadVendureConfigResult {
  109. vendureConfig: VendureConfig;
  110. exportedSymbolName: string;
  111. pluginInfo: PluginInfo[];
  112. }
  113. /**
  114. * @description
  115. * This function compiles the given Vendure config file and any imported relative files (i.e.
  116. * project files, not npm packages) into a temporary directory, and returns the compiled config.
  117. *
  118. * The reason we need to do this is that Vendure code makes use of TypeScript experimental decorators
  119. * (e.g. for NestJS decorators and TypeORM column decorators) which are not supported by esbuild.
  120. *
  121. * In Vite, when we load some TypeScript into the top-level Vite config file (in the end-user project), Vite
  122. * internally uses esbuild to temporarily compile that TypeScript code. Unfortunately, esbuild does not support
  123. * these experimental decorators, errors will be thrown as soon as e.g. a TypeORM column decorator is encountered.
  124. *
  125. * To work around this, we compile the Vendure config file and all its imports using the TypeScript compiler,
  126. * which fully supports these experimental decorators. The compiled files are then loaded by Vite, which is able
  127. * to handle the compiled JavaScript output.
  128. */
  129. export async function loadVendureConfig(options: ConfigLoaderOptions): Promise<LoadVendureConfigResult> {
  130. const { vendureConfigPath, vendureConfigExport, tempDir, pathAdapter } = options;
  131. const getCompiledConfigPath =
  132. pathAdapter?.getCompiledConfigPath ?? defaultPathAdapter.getCompiledConfigPath;
  133. const transformTsConfigPathMappings =
  134. pathAdapter?.transformTsConfigPathMappings ?? defaultPathAdapter.transformTsConfigPathMappings;
  135. const logger = options.logger || defaultLogger;
  136. const outputPath = tempDir;
  137. const configFileName = path.basename(vendureConfigPath);
  138. const inputRootDir = path.dirname(vendureConfigPath);
  139. await fs.remove(outputPath);
  140. const pluginInfo = await compileFile({
  141. inputRootDir,
  142. inputPath: vendureConfigPath,
  143. outputDir: outputPath,
  144. logger,
  145. transformTsConfigPathMappings,
  146. });
  147. const compiledConfigFilePath = pathToFileURL(
  148. getCompiledConfigPath({
  149. inputRootDir,
  150. outputPath,
  151. configFileName,
  152. }),
  153. ).href.replace(/.ts$/, '.js');
  154. // create package.json with type commonjs and save it to the output dir
  155. await fs.writeFile(path.join(outputPath, 'package.json'), JSON.stringify({ type: 'commonjs' }, null, 2));
  156. // We need to figure out the symbol exported by the config file by
  157. // analyzing the AST and finding an export with the type "VendureConfig"
  158. const sourceFile = ts.createSourceFile(
  159. vendureConfigPath,
  160. await fs.readFile(vendureConfigPath, 'utf-8'),
  161. ts.ScriptTarget.Latest,
  162. true,
  163. );
  164. const detectedExportedSymbolName = findConfigExport(sourceFile);
  165. const configExportedSymbolName = detectedExportedSymbolName || vendureConfigExport;
  166. if (!configExportedSymbolName) {
  167. throw new Error(
  168. `Could not find a variable exported as VendureConfig. Please specify the name of the exported variable using the "vendureConfigExport" option.`,
  169. );
  170. }
  171. // Register path aliases from tsconfig before importing
  172. const tsConfigInfo = await findTsConfigPaths(
  173. vendureConfigPath,
  174. logger,
  175. 'loading',
  176. transformTsConfigPathMappings,
  177. );
  178. if (tsConfigInfo) {
  179. tsConfigPaths.register({
  180. baseUrl: outputPath,
  181. paths: tsConfigInfo.paths,
  182. });
  183. }
  184. const config = await import(compiledConfigFilePath).then(m => m[configExportedSymbolName]);
  185. if (!config) {
  186. throw new Error(
  187. `Could not find a variable exported as VendureConfig with the name "${configExportedSymbolName}".`,
  188. );
  189. }
  190. return { vendureConfig: config, exportedSymbolName: configExportedSymbolName, pluginInfo };
  191. }
  192. /**
  193. * Finds and parses tsconfig files in the given directory and its parent directories.
  194. * Returns the paths configuration if found.
  195. */
  196. async function findTsConfigPaths(
  197. configPath: string,
  198. logger: Logger,
  199. phase: 'compiling' | 'loading',
  200. transformTsConfigPathMappings: Required<PathAdapter>['transformTsConfigPathMappings'],
  201. ): Promise<{ baseUrl: string; paths: Record<string, string[]> } | undefined> {
  202. const configDir = path.dirname(configPath);
  203. let currentDir = configDir;
  204. while (currentDir !== path.parse(currentDir).root) {
  205. try {
  206. const files = await fs.readdir(currentDir);
  207. const tsConfigFiles = files.filter(file => /^tsconfig(\..*)?\.json$/.test(file));
  208. for (const fileName of tsConfigFiles) {
  209. const tsConfigPath = path.join(currentDir, fileName);
  210. try {
  211. const tsConfigContent = await fs.readFile(tsConfigPath, 'utf-8');
  212. // Use JSON5 or similar parser if comments are expected in tsconfig.json
  213. // For simplicity, assuming standard JSON here. Handle parse errors.
  214. const tsConfig = JSON.parse(tsConfigContent);
  215. const compilerOptions = tsConfig.compilerOptions || {};
  216. if (compilerOptions.paths) {
  217. // Determine the effective baseUrl: explicitly set or the directory of tsconfig.json
  218. const tsConfigBaseUrl = path.resolve(currentDir, compilerOptions.baseUrl || '.');
  219. const paths: Record<string, string[]> = {};
  220. for (const [alias, patterns] of Object.entries(compilerOptions.paths)) {
  221. // Store paths as defined in tsconfig, they will be relative to baseUrl
  222. const normalizedPatterns = (patterns as string[]).map(pattern =>
  223. // Normalize slashes for consistency, keep relative
  224. pattern.replace(/\\/g, '/'),
  225. );
  226. paths[alias] = transformTsConfigPathMappings({
  227. phase,
  228. alias,
  229. patterns: normalizedPatterns,
  230. });
  231. }
  232. logger.debug(
  233. `Found tsconfig paths in ${tsConfigPath}: ${JSON.stringify(
  234. {
  235. baseUrl: tsConfigBaseUrl,
  236. paths,
  237. },
  238. null,
  239. 2,
  240. )}`,
  241. );
  242. return { baseUrl: tsConfigBaseUrl, paths };
  243. }
  244. } catch (e: any) {
  245. logger.warn(
  246. `Could not read or parse tsconfig file ${tsConfigPath}: ${e.message as string}`,
  247. );
  248. }
  249. }
  250. } catch (e: any) {
  251. // If we can't read the directory, just continue to the parent
  252. logger.warn(`Could not read directory ${currentDir}: ${e.message as string}`);
  253. }
  254. currentDir = path.dirname(currentDir);
  255. }
  256. logger.debug(`No tsconfig paths found traversing up from ${configDir}`);
  257. return undefined;
  258. }
  259. type CompileFileOptions = {
  260. inputRootDir: string;
  261. inputPath: string;
  262. outputDir: string;
  263. transformTsConfigPathMappings: Required<PathAdapter>['transformTsConfigPathMappings'];
  264. logger?: Logger;
  265. compiledFiles?: Set<string>;
  266. isRoot?: boolean;
  267. pluginInfo?: PluginInfo[];
  268. reportCompilationErrors?: boolean;
  269. };
  270. export async function compileFile({
  271. inputRootDir,
  272. inputPath,
  273. outputDir,
  274. transformTsConfigPathMappings,
  275. logger = defaultLogger,
  276. compiledFiles = new Set<string>(),
  277. isRoot = true,
  278. pluginInfo = [],
  279. reportCompilationErrors = false,
  280. }: CompileFileOptions): Promise<PluginInfo[]> {
  281. const absoluteInputPath = path.resolve(inputPath);
  282. if (compiledFiles.has(absoluteInputPath)) {
  283. return pluginInfo;
  284. }
  285. compiledFiles.add(absoluteInputPath);
  286. // Ensure output directory exists
  287. await fs.ensureDir(outputDir);
  288. // Read the source file
  289. const source = await fs.readFile(inputPath, 'utf-8');
  290. // Parse the source to find relative imports
  291. const sourceFile = ts.createSourceFile(absoluteInputPath, source, ts.ScriptTarget.Latest, true);
  292. const importPaths = new Set<string>();
  293. let tsConfigInfo: { baseUrl: string; paths: Record<string, string[]> } | undefined;
  294. if (isRoot) {
  295. tsConfigInfo = await findTsConfigPaths(
  296. absoluteInputPath,
  297. logger,
  298. 'compiling',
  299. transformTsConfigPathMappings,
  300. );
  301. if (tsConfigInfo) {
  302. logger?.debug(`Using TypeScript configuration: ${JSON.stringify(tsConfigInfo, null, 2)}`);
  303. }
  304. }
  305. async function collectImports(node: ts.Node) {
  306. if (
  307. (ts.isExportDeclaration(node) || ts.isImportDeclaration(node)) &&
  308. node.moduleSpecifier &&
  309. ts.isStringLiteral(node.moduleSpecifier)
  310. ) {
  311. const importPath = node.moduleSpecifier.text;
  312. // Handle relative imports
  313. if (importPath.startsWith('.')) {
  314. const resolvedPath = path.resolve(path.dirname(absoluteInputPath), importPath);
  315. // TODO: does this handle index files correctly?
  316. let resolvedTsPath = resolvedPath + '.ts';
  317. // Also check for .tsx if .ts doesn't exist
  318. if (!(await fs.pathExists(resolvedTsPath))) {
  319. const resolvedTsxPath = resolvedPath + '.tsx';
  320. if (await fs.pathExists(resolvedTsxPath)) {
  321. resolvedTsPath = resolvedTsxPath;
  322. } else {
  323. // If neither exists, maybe it's an index file?
  324. const resolvedIndexPath = path.join(resolvedPath, 'index.ts');
  325. if (await fs.pathExists(resolvedIndexPath)) {
  326. resolvedTsPath = resolvedIndexPath;
  327. } else {
  328. const resolvedIndexTsxPath = path.join(resolvedPath, 'index.tsx');
  329. if (await fs.pathExists(resolvedIndexTsxPath)) {
  330. resolvedTsPath = resolvedIndexTsxPath;
  331. } else {
  332. // If still not found, log a warning or let TS handle it later
  333. logger?.warn(
  334. `Could not resolve relative import "${importPath}" from "${absoluteInputPath}" to an existing .ts/.tsx file.`,
  335. );
  336. // Do not add to importPaths if we can't verify existence
  337. return;
  338. }
  339. }
  340. }
  341. }
  342. importPaths.add(resolvedTsPath);
  343. }
  344. // Handle path aliases if tsConfigInfo exists
  345. else if (tsConfigInfo) {
  346. // Attempt to resolve using path aliases
  347. let resolved = false;
  348. for (const [alias, patterns] of Object.entries(tsConfigInfo.paths)) {
  349. const aliasPrefix = alias.replace('*', '');
  350. const aliasSuffix = alias.endsWith('*') ? '*' : '';
  351. if (
  352. importPath.startsWith(aliasPrefix) &&
  353. (aliasSuffix === '*' || importPath === aliasPrefix)
  354. ) {
  355. const remainingImportPath = importPath.slice(aliasPrefix.length);
  356. for (const pattern of patterns) {
  357. const patternPrefix = pattern.replace('*', '');
  358. const patternSuffix = pattern.endsWith('*') ? '*' : '';
  359. // Ensure suffix match consistency (* vs exact)
  360. if (aliasSuffix !== patternSuffix) continue;
  361. const potentialPathBase = path.resolve(tsConfigInfo.baseUrl, patternPrefix);
  362. const resolvedPath = path.join(potentialPathBase, remainingImportPath);
  363. let resolvedTsPath = resolvedPath.endsWith('.ts')
  364. ? resolvedPath
  365. : resolvedPath + '.ts';
  366. // Similar existence checks as relative paths
  367. if (!(await fs.pathExists(resolvedTsPath))) {
  368. const resolvedTsxPath = resolvedPath + '.tsx';
  369. if (await fs.pathExists(resolvedTsxPath)) {
  370. resolvedTsPath = resolvedTsxPath;
  371. } else {
  372. const resolvedIndexPath = path.join(resolvedPath, 'index.ts');
  373. if (await fs.pathExists(resolvedIndexPath)) {
  374. resolvedTsPath = resolvedIndexPath;
  375. } else {
  376. const resolvedIndexTsxPath = path.join(resolvedPath, 'index.tsx');
  377. if (await fs.pathExists(resolvedIndexTsxPath)) {
  378. resolvedTsPath = resolvedIndexTsxPath;
  379. } else {
  380. // Path doesn't resolve to a file for this pattern
  381. continue;
  382. }
  383. }
  384. }
  385. }
  386. // Add the first successful resolution for this alias
  387. importPaths.add(resolvedTsPath);
  388. resolved = true;
  389. break; // Stop checking patterns for this alias
  390. }
  391. }
  392. if (resolved) break; // Stop checking other aliases if resolved
  393. }
  394. }
  395. // For all other imports (node_modules, etc), we should still add them to be processed
  396. // by the TypeScript compiler, even if we can't resolve them to a file
  397. else {
  398. // Add the import path as is - TypeScript will handle resolution
  399. // importPaths.add(importPath);
  400. }
  401. } else {
  402. const children = node.getChildren();
  403. for (const child of children) {
  404. // Only process nodes that could contain import statements
  405. if (
  406. ts.isSourceFile(child) ||
  407. ts.isModuleBlock(child) ||
  408. ts.isModuleDeclaration(child) ||
  409. ts.isImportDeclaration(child) ||
  410. ts.isExportDeclaration(child) ||
  411. child.kind === ts.SyntaxKind.SyntaxList
  412. ) {
  413. await collectImports(child);
  414. }
  415. }
  416. }
  417. }
  418. // Start collecting imports from the source file
  419. await collectImports(sourceFile);
  420. const extractedPluginInfo = getPluginInfo(sourceFile);
  421. if (extractedPluginInfo) {
  422. pluginInfo.push(extractedPluginInfo);
  423. }
  424. // Store the tsConfigInfo on the first call if found
  425. const rootTsConfigInfo = isRoot ? tsConfigInfo : undefined;
  426. // Recursively collect all files that need to be compiled
  427. for (const importPath of importPaths) {
  428. // Pass rootTsConfigInfo down, but set isRoot to false
  429. await compileFile({
  430. inputRootDir,
  431. inputPath: importPath,
  432. outputDir,
  433. logger,
  434. transformTsConfigPathMappings,
  435. compiledFiles,
  436. isRoot: false,
  437. pluginInfo,
  438. });
  439. }
  440. // If this is the root file (the one that started the compilation),
  441. // use the TypeScript compiler API to compile all files together
  442. if (isRoot) {
  443. logger.info(`Starting compilation for ${compiledFiles.size} files...`);
  444. const allFiles = Array.from(compiledFiles);
  445. const compilerOptions: ts.CompilerOptions = {
  446. // Base options
  447. target: ts.ScriptTarget.ES2020,
  448. module: ts.ModuleKind.CommonJS, // Output CommonJS for Node compatibility
  449. experimentalDecorators: true,
  450. emitDecoratorMetadata: true,
  451. esModuleInterop: true,
  452. skipLibCheck: true, // Faster compilation
  453. forceConsistentCasingInFileNames: true,
  454. moduleResolution: ts.ModuleResolutionKind.NodeJs, // Use Node.js module resolution
  455. incremental: false, // No need for incremental compilation
  456. noEmitOnError: false, // Continue emitting even with errors
  457. isolatedModules: true, // Treat files as separate modules
  458. strict: false, // Disable strict type checking for speed
  459. noUnusedLocals: false, // Skip unused locals check
  460. noUnusedParameters: false, // Skip unused parameters check
  461. // Output options
  462. outDir: outputDir, // Output directory for all compiled files
  463. sourceMap: false, // Generate source maps
  464. declaration: false, // Don't generate .d.ts files
  465. // Path resolution options - use info found from tsconfig
  466. baseUrl: rootTsConfigInfo ? rootTsConfigInfo.baseUrl : undefined, // Let TS handle resolution if no baseUrl
  467. paths: rootTsConfigInfo ? rootTsConfigInfo.paths : undefined,
  468. // rootDir: inputRootDir, // Often inferred correctly, can cause issues if set explicitly sometimes
  469. allowJs: true, // Allow JS files if needed, though we primarily collect TS
  470. resolveJsonModule: true, // Allow importing JSON
  471. };
  472. logger.debug(`compilerOptions: ${JSON.stringify(compilerOptions, null, 2)}`);
  473. // Create a Program to represent the compilation context
  474. const program = ts.createProgram(allFiles, compilerOptions);
  475. logger.info(`Emitting compiled files to ${outputDir}`);
  476. const emitResult = program.emit();
  477. if (reportCompilationErrors) {
  478. const hasEmitErrors = reportDiagnostics(program, emitResult, logger);
  479. if (hasEmitErrors) {
  480. throw new Error('TypeScript compilation failed with errors.');
  481. }
  482. }
  483. logger.info(`Successfully compiled ${allFiles.length} files to ${outputDir}`);
  484. }
  485. return pluginInfo;
  486. }
  487. function reportDiagnostics(program: ts.Program, emitResult: ts.EmitResult, logger: Logger) {
  488. const allDiagnostics = ts.getPreEmitDiagnostics(program).concat(emitResult.diagnostics);
  489. let hasEmitErrors = emitResult.emitSkipped;
  490. allDiagnostics.forEach(diagnostic => {
  491. if (diagnostic.file && diagnostic.start) {
  492. const { line, character } = ts.getLineAndCharacterOfPosition(diagnostic.file, diagnostic.start);
  493. const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
  494. const logFn = diagnostic.category === ts.DiagnosticCategory.Error ? logger.warn : logger.info;
  495. // eslint-disable-next-line no-console
  496. console.log(
  497. `TS${diagnostic.code} ${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message}`,
  498. );
  499. if (diagnostic.category === ts.DiagnosticCategory.Error) {
  500. hasEmitErrors = true;
  501. }
  502. } else {
  503. const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
  504. const logFn = diagnostic.category === ts.DiagnosticCategory.Error ? logger.warn : logger.info;
  505. // eslint-disable-next-line no-console
  506. console.log(`TS${diagnostic.code}: ${message}`);
  507. if (diagnostic.category === ts.DiagnosticCategory.Error) {
  508. hasEmitErrors = true;
  509. }
  510. }
  511. });
  512. return hasEmitErrors;
  513. }