config-loader.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. import { Options, parse, transform } from '@swc/core';
  2. import { BindingIdentifier, ModuleItem, Pattern, Statement } from '@swc/types';
  3. import { VendureConfig } from '@vendure/core';
  4. import fs from 'fs-extra';
  5. import path from 'path';
  6. import { pathToFileURL } from 'url';
  7. export interface ConfigLoaderOptions {
  8. vendureConfigPath: string;
  9. tempDir: string;
  10. vendureConfigExport?: string;
  11. }
  12. /**
  13. * @description
  14. * This function compiles the given Vendure config file and any imported relative files (i.e.
  15. * project files, not npm packages) into a temporary directory, and returns the compiled config.
  16. *
  17. * The reason we need to do this is that Vendure code makes use of TypeScript experimental decorators
  18. * (e.g. for NestJS decorators and TypeORM column decorators) which are not supported by esbuild.
  19. *
  20. * In Vite, when we load some TypeScript into the top-level Vite config file (in the end-user project), Vite
  21. * internally uses esbuild to temporarily compile that TypeScript code. Unfortunately, esbuild does not support
  22. * these experimental decorators, errors will be thrown as soon as e.g. a TypeORM column decorator is encountered.
  23. *
  24. * To work around this, we compile the Vendure config file and all its imports using SWC, which does support
  25. * these experimental decorators. The compiled files are then loaded by Vite, which is able to handle the compiled
  26. * JavaScript output.
  27. */
  28. export async function loadVendureConfig(
  29. options: ConfigLoaderOptions,
  30. ): Promise<{ vendureConfig: VendureConfig; exportedSymbolName: string }> {
  31. const { vendureConfigPath, vendureConfigExport, tempDir } = options;
  32. const outputPath = tempDir;
  33. const configFileName = path.basename(vendureConfigPath);
  34. const inputRootDir = path.dirname(vendureConfigPath);
  35. await fs.remove(outputPath);
  36. await compileFile(inputRootDir, vendureConfigPath, outputPath);
  37. const compiledConfigFilePath = pathToFileURL(path.join(outputPath, configFileName)).href.replace(
  38. /.ts$/,
  39. '.js',
  40. );
  41. // create package.json with type commonjs and save it to the output dir
  42. await fs.writeFile(path.join(outputPath, 'package.json'), JSON.stringify({ type: 'commonjs' }, null, 2));
  43. // We need to figure out the symbol exported by the config file by
  44. // analyzing the AST and finding an export with the type "VendureConfig"
  45. const ast = await parse(await fs.readFile(vendureConfigPath, 'utf-8'), {
  46. syntax: 'typescript',
  47. decorators: true,
  48. });
  49. const detectedExportedSymbolName = findConfigExport(ast.body);
  50. const configExportedSymbolName = detectedExportedSymbolName || vendureConfigExport;
  51. if (!configExportedSymbolName) {
  52. throw new Error(
  53. `Could not find a variable exported as VendureConfig. Please specify the name of the exported variable using the "vendureConfigExport" option.`,
  54. );
  55. }
  56. const config = await import(compiledConfigFilePath).then(m => m[configExportedSymbolName]);
  57. if (!config) {
  58. throw new Error(
  59. `Could not find a variable exported as VendureConfig with the name "${configExportedSymbolName}".`,
  60. );
  61. }
  62. return { vendureConfig: config, exportedSymbolName: configExportedSymbolName };
  63. }
  64. /**
  65. * Given the AST of a TypeScript file, finds the name of the variable exported as VendureConfig.
  66. */
  67. function findConfigExport(statements: ModuleItem[]): string | undefined {
  68. for (const statement of statements) {
  69. if (statement.type === 'ExportDeclaration') {
  70. if (statement.declaration.type === 'VariableDeclaration') {
  71. for (const declaration of statement.declaration.declarations) {
  72. if (isBindingIdentifier(declaration.id)) {
  73. const typeRef = declaration.id.typeAnnotation?.typeAnnotation;
  74. if (typeRef?.type === 'TsTypeReference') {
  75. if (
  76. typeRef.typeName.type === 'Identifier' &&
  77. typeRef.typeName.value === 'VendureConfig'
  78. ) {
  79. return declaration.id.value;
  80. }
  81. }
  82. }
  83. }
  84. }
  85. }
  86. }
  87. return undefined;
  88. }
  89. function isBindingIdentifier(id: Pattern): id is BindingIdentifier {
  90. return id.type === 'Identifier' && !!(id as BindingIdentifier).typeAnnotation;
  91. }
  92. export async function compileFile(
  93. inputRootDir: string,
  94. inputPath: string,
  95. outputDir: string,
  96. compiledFiles = new Set<string>(),
  97. ): Promise<void> {
  98. if (compiledFiles.has(inputPath)) {
  99. return;
  100. }
  101. compiledFiles.add(inputPath);
  102. // Ensure output directory exists
  103. await fs.ensureDir(outputDir);
  104. // Read the source file
  105. const source = await fs.readFile(inputPath, 'utf-8');
  106. // Transform config
  107. const config: Options = {
  108. filename: inputPath,
  109. sourceMaps: true,
  110. jsc: {
  111. parser: {
  112. syntax: 'typescript',
  113. tsx: false,
  114. decorators: true,
  115. },
  116. target: 'es2020',
  117. loose: false,
  118. transform: {
  119. legacyDecorator: true,
  120. decoratorMetadata: true,
  121. },
  122. },
  123. module: {
  124. type: 'commonjs',
  125. strict: true,
  126. strictMode: true,
  127. lazy: false,
  128. noInterop: false,
  129. },
  130. };
  131. // Transform the code using SWC
  132. const result = await transform(source, config);
  133. // Generate output file path
  134. const relativePath = path.relative(inputRootDir, inputPath);
  135. const outputPath = path.join(outputDir, relativePath).replace(/\.ts$/, '.js');
  136. // Ensure the subdirectory for the output file exists
  137. await fs.ensureDir(path.dirname(outputPath));
  138. // Write the transformed code
  139. await fs.writeFile(outputPath, result.code);
  140. // Write source map if available
  141. if (result.map) {
  142. await fs.writeFile(`${outputPath}.map`, JSON.stringify(result.map));
  143. }
  144. // Parse the source to find relative imports
  145. const ast = await parse(source, { syntax: 'typescript', decorators: true });
  146. const importPaths = new Set<string>();
  147. function collectImports(node: any) {
  148. if (node.type === 'ImportDeclaration' && node.source.value.startsWith('.')) {
  149. const importPath = path.resolve(path.dirname(inputPath), node.source.value);
  150. importPaths.add(importPath + '.ts');
  151. }
  152. for (const key in node) {
  153. if (node[key] && typeof node[key] === 'object') {
  154. collectImports(node[key]);
  155. }
  156. }
  157. }
  158. collectImports(ast);
  159. // Recursively compile all relative imports
  160. for (const importPath of importPaths) {
  161. await compileFile(inputRootDir, importPath, outputDir, compiledFiles);
  162. }
  163. }