config-loader.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  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. await fs.remove(outputPath);
  35. await compileFile(vendureConfigPath, path.join(import.meta.dirname, './.vendure-dashboard-temp'));
  36. const compiledConfigFilePath = pathToFileURL(path.join(outputPath, configFileName)).href.replace(
  37. /.ts$/,
  38. '.js',
  39. );
  40. // create package.json with type commonjs and save it to the output dir
  41. await fs.writeFile(path.join(outputPath, 'package.json'), JSON.stringify({ type: 'commonjs' }, null, 2));
  42. // We need to figure out the symbol exported by the config file by
  43. // analyzing the AST and finding an export with the type "VendureConfig"
  44. const ast = await parse(await fs.readFile(vendureConfigPath, 'utf-8'), {
  45. syntax: 'typescript',
  46. decorators: true,
  47. });
  48. const detectedExportedSymbolName = findConfigExport(ast.body);
  49. const configExportedSymbolName = detectedExportedSymbolName || vendureConfigExport;
  50. if (!configExportedSymbolName) {
  51. throw new Error(
  52. `Could not find a variable exported as VendureConfig. Please specify the name of the exported variable using the "vendureConfigExport" option.`,
  53. );
  54. }
  55. const config = await import(compiledConfigFilePath).then(m => m[configExportedSymbolName]);
  56. if (!config) {
  57. throw new Error(
  58. `Could not find a variable exported as VendureConfig with the name "${configExportedSymbolName}".`,
  59. );
  60. }
  61. return { vendureConfig: config, exportedSymbolName: configExportedSymbolName };
  62. }
  63. /**
  64. * Given the AST of a TypeScript file, finds the name of the variable exported as VendureConfig.
  65. */
  66. function findConfigExport(statements: ModuleItem[]): string | undefined {
  67. for (const statement of statements) {
  68. if (statement.type === 'ExportDeclaration') {
  69. if (statement.declaration.type === 'VariableDeclaration') {
  70. for (const declaration of statement.declaration.declarations) {
  71. if (isBindingIdentifier(declaration.id)) {
  72. const typeRef = declaration.id.typeAnnotation?.typeAnnotation;
  73. if (typeRef?.type === 'TsTypeReference') {
  74. if (
  75. typeRef.typeName.type === 'Identifier' &&
  76. typeRef.typeName.value === 'VendureConfig'
  77. ) {
  78. return declaration.id.value;
  79. }
  80. }
  81. }
  82. }
  83. }
  84. }
  85. }
  86. return undefined;
  87. }
  88. function isBindingIdentifier(id: Pattern): id is BindingIdentifier {
  89. return id.type === 'Identifier' && !!(id as BindingIdentifier).typeAnnotation;
  90. }
  91. export async function compileFile(
  92. inputPath: string,
  93. outputDir: string,
  94. compiledFiles = new Set<string>(),
  95. ): Promise<void> {
  96. if (compiledFiles.has(inputPath)) {
  97. return;
  98. }
  99. compiledFiles.add(inputPath);
  100. // Ensure output directory exists
  101. await fs.ensureDir(outputDir);
  102. // Read the source file
  103. const source = await fs.readFile(inputPath, 'utf-8');
  104. // Transform config
  105. const config: Options = {
  106. filename: inputPath,
  107. sourceMaps: true,
  108. jsc: {
  109. parser: {
  110. syntax: 'typescript',
  111. tsx: false,
  112. decorators: true,
  113. },
  114. target: 'es2020',
  115. loose: false,
  116. transform: {
  117. legacyDecorator: true,
  118. decoratorMetadata: true,
  119. },
  120. },
  121. module: {
  122. type: 'commonjs',
  123. strict: true,
  124. strictMode: true,
  125. lazy: false,
  126. noInterop: false,
  127. },
  128. };
  129. // Transform the code using SWC
  130. const result = await transform(source, config);
  131. // Generate output file path
  132. const relativePath = path.relative(process.cwd(), inputPath);
  133. const outputPath = path.join(outputDir, relativePath).replace(/\.ts$/, '.js');
  134. // Ensure the subdirectory for the output file exists
  135. await fs.ensureDir(path.dirname(outputPath));
  136. // Write the transformed code
  137. await fs.writeFile(outputPath, result.code);
  138. // Write source map if available
  139. if (result.map) {
  140. await fs.writeFile(`${outputPath}.map`, JSON.stringify(result.map));
  141. }
  142. // Parse the source to find relative imports
  143. const ast = await parse(source, { syntax: 'typescript', decorators: true });
  144. const importPaths = new Set<string>();
  145. function collectImports(node: any) {
  146. if (node.type === 'ImportDeclaration' && node.source.value.startsWith('.')) {
  147. const importPath = path.resolve(path.dirname(inputPath), node.source.value);
  148. importPaths.add(importPath + '.ts');
  149. }
  150. for (const key in node) {
  151. if (node[key] && typeof node[key] === 'object') {
  152. collectImports(node[key]);
  153. }
  154. }
  155. }
  156. collectImports(ast);
  157. // Recursively compile all relative imports
  158. for (const importPath of importPaths) {
  159. await compileFile(importPath, outputDir, compiledFiles);
  160. }
  161. }