config-loader.ts 19 KB

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