config-loader.ts 19 KB

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