config-loader.ts 19 KB

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