compiler.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  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 { RegisterParams } from 'tsconfig-paths/lib/register.js';
  6. import * as ts from 'typescript';
  7. import { pathToFileURL } from 'url';
  8. import { Logger, PathAdapter, PluginInfo } from '../types.js';
  9. import { findConfigExport } from './ast-utils.js';
  10. import { noopLogger } from './logger.js';
  11. import { createPathTransformer } from './path-transformer.js';
  12. import { discoverPlugins } from './plugin-discovery.js';
  13. import { findTsConfigPaths } from './tsconfig-utils.js';
  14. const defaultPathAdapter: Required<PathAdapter> = {
  15. getCompiledConfigPath: ({ outputPath, configFileName }) => path.join(outputPath, configFileName),
  16. transformTsConfigPathMappings: ({ patterns }) => patterns,
  17. };
  18. export interface PackageScannerConfig {
  19. nodeModulesRoot?: string;
  20. }
  21. export interface CompilerOptions {
  22. vendureConfigPath: string;
  23. outputPath: string;
  24. pathAdapter?: PathAdapter;
  25. logger?: Logger;
  26. pluginPackageScanner?: PackageScannerConfig;
  27. module?: 'commonjs' | 'esm';
  28. }
  29. export interface CompileResult {
  30. vendureConfig: VendureConfig;
  31. exportedSymbolName: string;
  32. pluginInfo: PluginInfo[];
  33. }
  34. /**
  35. * Compiles TypeScript files and discovers Vendure plugins in both the compiled output
  36. * and in node_modules.
  37. */
  38. export async function compile(options: CompilerOptions): Promise<CompileResult> {
  39. const { vendureConfigPath, outputPath, pathAdapter, logger = noopLogger, pluginPackageScanner } = options;
  40. const getCompiledConfigPath =
  41. pathAdapter?.getCompiledConfigPath ?? defaultPathAdapter.getCompiledConfigPath;
  42. const transformTsConfigPathMappings =
  43. pathAdapter?.transformTsConfigPathMappings ?? defaultPathAdapter.transformTsConfigPathMappings;
  44. // 0. Clear the outputPath
  45. fs.removeSync(outputPath);
  46. // 1. Compile TypeScript files
  47. const compileStart = Date.now();
  48. await compileTypeScript({
  49. inputPath: vendureConfigPath,
  50. outputPath,
  51. logger,
  52. transformTsConfigPathMappings,
  53. module: options.module ?? 'commonjs',
  54. });
  55. logger.info(`TypeScript compilation completed in ${Date.now() - compileStart}ms`);
  56. // 2. Discover plugins
  57. const analyzePluginsStart = Date.now();
  58. const plugins = await discoverPlugins({
  59. vendureConfigPath,
  60. transformTsConfigPathMappings,
  61. logger,
  62. outputPath,
  63. pluginPackageScanner,
  64. });
  65. logger.info(
  66. `Analyzed plugins and found ${plugins.length} dashboard extensions in ${Date.now() - analyzePluginsStart}ms`,
  67. );
  68. // 3. Load the compiled config
  69. const configFileName = path.basename(vendureConfigPath);
  70. const compiledConfigFilePath = pathToFileURL(
  71. getCompiledConfigPath({
  72. inputRootDir: path.dirname(vendureConfigPath),
  73. outputPath,
  74. configFileName,
  75. }),
  76. ).href.replace(/.ts$/, '.js');
  77. // Create package.json with type commonjs
  78. await fs.writeFile(
  79. path.join(outputPath, 'package.json'),
  80. JSON.stringify({ type: options.module === 'esm' ? 'module' : 'commonjs', private: true }, null, 2),
  81. );
  82. // Find the exported config symbol
  83. const sourceFile = ts.createSourceFile(
  84. vendureConfigPath,
  85. await fs.readFile(vendureConfigPath, 'utf-8'),
  86. ts.ScriptTarget.Latest,
  87. true,
  88. );
  89. const exportedSymbolName = findConfigExport(sourceFile);
  90. if (!exportedSymbolName) {
  91. throw new Error(
  92. `Could not find a variable exported as VendureConfig. Please specify the name of the exported variable.`,
  93. );
  94. }
  95. const loadConfigStart = Date.now();
  96. await registerTsConfigPaths({
  97. outputPath,
  98. configPath: vendureConfigPath,
  99. logger,
  100. phase: 'loading',
  101. transformTsConfigPathMappings,
  102. });
  103. let config: any;
  104. try {
  105. config = await import(compiledConfigFilePath).then(m => m[exportedSymbolName]);
  106. } catch (e: any) {
  107. const errorMessage =
  108. e instanceof Error ? `${e.message}\n${e.stack ?? ''}` : JSON.stringify(e, null, 2);
  109. logger.error(`Error loading config: ${errorMessage}`);
  110. }
  111. if (!config) {
  112. throw new Error(
  113. `Could not find a variable exported as VendureConfig with the name "${exportedSymbolName}".`,
  114. );
  115. }
  116. logger.debug(`Loaded config in ${Date.now() - loadConfigStart}ms`);
  117. return { vendureConfig: config, exportedSymbolName, pluginInfo: plugins };
  118. }
  119. /**
  120. * Compiles TypeScript files to JavaScript
  121. */
  122. async function compileTypeScript({
  123. inputPath,
  124. outputPath,
  125. logger,
  126. transformTsConfigPathMappings,
  127. module,
  128. }: {
  129. inputPath: string;
  130. outputPath: string;
  131. logger: Logger;
  132. transformTsConfigPathMappings: Required<PathAdapter>['transformTsConfigPathMappings'];
  133. module: 'commonjs' | 'esm';
  134. }): Promise<void> {
  135. await fs.ensureDir(outputPath);
  136. // Find tsconfig paths - we need BOTH original and transformed versions
  137. // Original paths: Used by TypeScript for module resolution during compilation
  138. // Transformed paths: Used by the path transformer for rewriting output imports
  139. const originalTsConfigInfo = await findTsConfigPaths(
  140. inputPath,
  141. logger,
  142. 'compiling',
  143. ({ patterns }) => patterns, // No transformation - use original paths
  144. );
  145. const transformedTsConfigInfo = await findTsConfigPaths(
  146. inputPath,
  147. logger,
  148. 'compiling',
  149. transformTsConfigPathMappings, // Apply user's transformation
  150. );
  151. const compilerOptions: ts.CompilerOptions = {
  152. target: ts.ScriptTarget.ES2020,
  153. module: module === 'esm' ? ts.ModuleKind.ESNext : ts.ModuleKind.CommonJS,
  154. moduleResolution: ts.ModuleResolutionKind.Node10, // More explicit CJS resolution
  155. experimentalDecorators: true,
  156. emitDecoratorMetadata: true,
  157. esModuleInterop: true,
  158. skipLibCheck: true,
  159. noEmit: false,
  160. // Speed optimizations
  161. noEmitOnError: false, // Emit output even if there are errors
  162. noImplicitAny: false, // Don't require implicit any
  163. noUnusedLocals: false, // Don't check for unused locals
  164. noUnusedParameters: false, // Don't check for unused parameters
  165. allowJs: true,
  166. checkJs: false, // Don't type check JS files
  167. skipDefaultLibCheck: true, // Skip checking .d.ts files
  168. isolatedModules: false, // Need to check cross-file references to compile dependencies
  169. incremental: false, // Don't use incremental compilation (faster for one-off builds)
  170. resolveJsonModule: true,
  171. preserveSymlinks: false,
  172. outDir: outputPath,
  173. };
  174. logger.debug(`Compiling ${inputPath} to ${outputPath} using TypeScript...`);
  175. // Add path mappings if found - use ORIGINAL paths for TypeScript module resolution
  176. if (originalTsConfigInfo) {
  177. // We need to set baseUrl and paths for TypeScript to resolve the imports
  178. compilerOptions.baseUrl = originalTsConfigInfo.baseUrl;
  179. compilerOptions.paths = originalTsConfigInfo.paths;
  180. // This is critical - it tells TypeScript to preserve the paths in the output
  181. // compilerOptions.rootDir = originalTsConfigInfo.baseUrl;
  182. }
  183. logger.debug(`tsConfig original paths: ${JSON.stringify(originalTsConfigInfo?.paths, null, 2)}`);
  184. logger.debug(`tsConfig transformed paths: ${JSON.stringify(transformedTsConfigInfo?.paths, null, 2)}`);
  185. logger.debug(`tsConfig baseUrl: ${originalTsConfigInfo?.baseUrl ?? 'UNKNOWN'}`);
  186. // Build custom transformers
  187. const afterTransformers: Array<ts.TransformerFactory<ts.SourceFile>> = [];
  188. // Add path transformer for ESM mode when there are path mappings
  189. // This is necessary because tsconfig-paths.register() only works for CommonJS require(),
  190. // not for ESM import(). We need to transform the import paths during compilation.
  191. //
  192. // IMPORTANT: We use 'after' transformers because:
  193. // 1. 'before' runs before TypeScript resolves modules - changing paths there breaks resolution
  194. // 2. 'after' runs after module resolution but before emit - paths are transformed in output only
  195. //
  196. // We use TRANSFORMED paths here because the output directory structure may differ from source
  197. if (module === 'esm' && transformedTsConfigInfo) {
  198. logger.debug('Adding path transformer for ESM mode');
  199. afterTransformers.push(
  200. createPathTransformer({
  201. baseUrl: transformedTsConfigInfo.baseUrl,
  202. paths: transformedTsConfigInfo.paths,
  203. }),
  204. );
  205. }
  206. // Add the existing transformer for non-entry-point files
  207. afterTransformers.push(context => {
  208. return sourceFile => {
  209. // Only transform files that are not the entry point
  210. if (sourceFile.fileName === inputPath) {
  211. return sourceFile;
  212. }
  213. sourceFile.fileName = path.join(outputPath, path.basename(sourceFile.fileName));
  214. return sourceFile;
  215. };
  216. });
  217. const customTransformers: ts.CustomTransformers = {
  218. after: afterTransformers,
  219. };
  220. const program = ts.createProgram([inputPath], compilerOptions);
  221. const emitResult = program.emit(undefined, undefined, undefined, undefined, customTransformers);
  222. // Only log actual emit errors, not type errors
  223. if (emitResult.emitSkipped) {
  224. for (const diagnostic of emitResult.diagnostics) {
  225. if (diagnostic.file && diagnostic.start !== undefined) {
  226. const { line, character } = ts.getLineAndCharacterOfPosition(
  227. diagnostic.file,
  228. diagnostic.start,
  229. );
  230. const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
  231. logger.warn(`${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message}`);
  232. } else {
  233. logger.warn(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'));
  234. }
  235. }
  236. }
  237. }
  238. async function registerTsConfigPaths(options: {
  239. outputPath: string;
  240. configPath: string;
  241. logger: Logger;
  242. phase: 'compiling' | 'loading';
  243. transformTsConfigPathMappings: Required<PathAdapter>['transformTsConfigPathMappings'];
  244. }) {
  245. const { outputPath, configPath, logger, phase, transformTsConfigPathMappings } = options;
  246. const tsConfigInfo = await findTsConfigPaths(configPath, logger, phase, transformTsConfigPathMappings);
  247. if (tsConfigInfo) {
  248. const params: RegisterParams = {
  249. baseUrl: outputPath,
  250. paths: tsConfigInfo.paths,
  251. };
  252. logger.debug(`Registering tsconfig paths: ${JSON.stringify(params, null, 2)}`);
  253. tsConfigPaths.register(params);
  254. }
  255. }