Ver Fonte

fix(dashboard): Resolve tsconfig path aliases in ESM mode (#4134)

Michael Bromley há 1 dia atrás
pai
commit
a99500d5bf

+ 40 - 0
packages/dashboard/vite/tests/esm-path-alias.spec.ts

@@ -0,0 +1,40 @@
+import { rm } from 'node:fs/promises';
+import { join } from 'node:path';
+import { describe, expect, it } from 'vitest';
+
+import { compile } from '../utils/compiler.js';
+import { debugLogger, noopLogger } from '../utils/logger.js';
+
+describe('handling ESM projects with tsconfig path aliases', () => {
+    it('should compile ESM project with path aliases', { timeout: 60_000 }, async () => {
+        const tempDir = join(__dirname, './__temp/esm-path-alias');
+        await rm(tempDir, { recursive: true, force: true });
+        const result = await compile({
+            outputPath: tempDir,
+            vendureConfigPath: join(__dirname, 'fixtures-esm-path-alias', 'vendure-config.ts'),
+            logger: process.env.LOG ? debugLogger : noopLogger,
+            module: 'esm',
+            pathAdapter: {
+                transformTsConfigPathMappings: ({ phase, patterns }) => {
+                    // For both compiling and loading phases, we need to strip the fixtures directory prefix
+                    // because the compiled output flattens the directory structure
+                    return patterns.map(pattern => {
+                        let transformed = pattern.replace(/\.\/fixtures-esm-path-alias\//, './');
+                        if (phase === 'loading') {
+                            transformed = transformed.replace(/.ts$/, '.js');
+                        }
+                        return transformed;
+                    });
+                },
+            },
+        });
+
+        expect(result.pluginInfo).toHaveLength(1);
+        expect(result.pluginInfo[0].name).toBe('MyPlugin');
+        expect(result.pluginInfo[0].dashboardEntryPath).toBe('./dashboard/index.tsx');
+        // sourcePluginPath is undefined because the plugin is discovered from compiled output,
+        // not from source analysis (path aliases in source can't be followed without runtime resolution)
+        expect(result.pluginInfo[0].sourcePluginPath).toBeUndefined();
+        expect(result.pluginInfo[0].pluginPath).toBe(join(tempDir, 'my-plugin', 'src', 'my.plugin.js'));
+    });
+});

+ 1 - 0
packages/dashboard/vite/tests/fixtures-esm-path-alias/my-plugin/index.ts

@@ -0,0 +1 @@
+export { MyPlugin } from './src/my.plugin.js';

+ 3 - 0
packages/dashboard/vite/tests/fixtures-esm-path-alias/my-plugin/src/dashboard/index.tsx

@@ -0,0 +1,3 @@
+export const MyDashboardExtension = () => {
+    return <div>My Extension</div>;
+};

+ 8 - 0
packages/dashboard/vite/tests/fixtures-esm-path-alias/my-plugin/src/my.plugin.ts

@@ -0,0 +1,8 @@
+import { PluginCommonModule, VendurePlugin } from '@vendure/core';
+
+@VendurePlugin({
+    imports: [PluginCommonModule],
+    providers: [],
+    dashboard: './dashboard/index.tsx',
+})
+export class MyPlugin {}

+ 6 - 0
packages/dashboard/vite/tests/fixtures-esm-path-alias/package.json

@@ -0,0 +1,6 @@
+{
+    "type": "module",
+    "name": "esm-path-alias",
+    "version": "0.0.1",
+    "main": "index.ts"
+}

+ 20 - 0
packages/dashboard/vite/tests/fixtures-esm-path-alias/vendure-config.ts

@@ -0,0 +1,20 @@
+import { MyPlugin } from '@esm-plugins/my-plugin';
+import { VendureConfig } from '@vendure/core';
+
+const somePath = import.meta.url;
+
+export const config: VendureConfig = {
+    apiOptions: {
+        port: 3000,
+    },
+    authOptions: {
+        tokenMethod: 'bearer',
+    },
+    dbConnectionOptions: {
+        type: 'postgres',
+    },
+    paymentOptions: {
+        paymentMethodHandlers: [],
+    },
+    plugins: [MyPlugin],
+};

+ 3 - 0
packages/dashboard/vite/tests/tsconfig.json

@@ -12,6 +12,9 @@
       ],
       ],
       "@other/js-aliased": [
       "@other/js-aliased": [
         "./fixtures-path-alias/js-aliased/index.js"
         "./fixtures-path-alias/js-aliased/index.js"
+      ],
+      "@esm-plugins/*": [
+        "./fixtures-esm-path-alias/*"
       ]
       ]
     }
     }
   },
   },

+ 59 - 25
packages/dashboard/vite/utils/compiler.ts

@@ -10,6 +10,7 @@ import { Logger, PathAdapter, PluginInfo } from '../types.js';
 
 
 import { findConfigExport } from './ast-utils.js';
 import { findConfigExport } from './ast-utils.js';
 import { noopLogger } from './logger.js';
 import { noopLogger } from './logger.js';
+import { createPathTransformer } from './path-transformer.js';
 import { discoverPlugins } from './plugin-discovery.js';
 import { discoverPlugins } from './plugin-discovery.js';
 import { findTsConfigPaths } from './tsconfig-utils.js';
 import { findTsConfigPaths } from './tsconfig-utils.js';
 
 
@@ -118,8 +119,10 @@ export async function compile(options: CompilerOptions): Promise<CompileResult>
     let config: any;
     let config: any;
     try {
     try {
         config = await import(compiledConfigFilePath).then(m => m[exportedSymbolName]);
         config = await import(compiledConfigFilePath).then(m => m[exportedSymbolName]);
-    } catch (e) {
-        logger.error(`Error loading config: ${e instanceof Error ? e.message : String(e)}`);
+    } catch (e: any) {
+        const errorMessage =
+            e instanceof Error ? `${e.message}\n${e.stack ?? ''}` : JSON.stringify(e, null, 2);
+        logger.error(`Error loading config: ${errorMessage}`);
     }
     }
     if (!config) {
     if (!config) {
         throw new Error(
         throw new Error(
@@ -149,12 +152,20 @@ async function compileTypeScript({
 }): Promise<void> {
 }): Promise<void> {
     await fs.ensureDir(outputPath);
     await fs.ensureDir(outputPath);
 
 
-    // Find tsconfig paths first
-    const tsConfigInfo = await findTsConfigPaths(
+    // Find tsconfig paths - we need BOTH original and transformed versions
+    // Original paths: Used by TypeScript for module resolution during compilation
+    // Transformed paths: Used by the path transformer for rewriting output imports
+    const originalTsConfigInfo = await findTsConfigPaths(
         inputPath,
         inputPath,
         logger,
         logger,
         'compiling',
         'compiling',
-        transformTsConfigPathMappings,
+        ({ patterns }) => patterns, // No transformation - use original paths
+    );
+    const transformedTsConfigInfo = await findTsConfigPaths(
+        inputPath,
+        logger,
+        'compiling',
+        transformTsConfigPathMappings, // Apply user's transformation
     );
     );
 
 
     const compilerOptions: ts.CompilerOptions = {
     const compilerOptions: ts.CompilerOptions = {
@@ -183,32 +194,55 @@ async function compileTypeScript({
 
 
     logger.debug(`Compiling ${inputPath} to ${outputPath} using TypeScript...`);
     logger.debug(`Compiling ${inputPath} to ${outputPath} using TypeScript...`);
 
 
-    // Add path mappings if found
-    if (tsConfigInfo) {
+    // Add path mappings if found - use ORIGINAL paths for TypeScript module resolution
+    if (originalTsConfigInfo) {
         // We need to set baseUrl and paths for TypeScript to resolve the imports
         // We need to set baseUrl and paths for TypeScript to resolve the imports
-        compilerOptions.baseUrl = tsConfigInfo.baseUrl;
-        compilerOptions.paths = tsConfigInfo.paths;
+        compilerOptions.baseUrl = originalTsConfigInfo.baseUrl;
+        compilerOptions.paths = originalTsConfigInfo.paths;
         // This is critical - it tells TypeScript to preserve the paths in the output
         // This is critical - it tells TypeScript to preserve the paths in the output
-        // compilerOptions.rootDir = tsConfigInfo.baseUrl;
+        // compilerOptions.rootDir = originalTsConfigInfo.baseUrl;
+    }
+
+    logger.debug(`tsConfig original paths: ${JSON.stringify(originalTsConfigInfo?.paths, null, 2)}`);
+    logger.debug(`tsConfig transformed paths: ${JSON.stringify(transformedTsConfigInfo?.paths, null, 2)}`);
+    logger.debug(`tsConfig baseUrl: ${originalTsConfigInfo?.baseUrl ?? 'UNKNOWN'}`);
+
+    // Build custom transformers
+    const afterTransformers: Array<ts.TransformerFactory<ts.SourceFile>> = [];
+
+    // Add path transformer for ESM mode when there are path mappings
+    // This is necessary because tsconfig-paths.register() only works for CommonJS require(),
+    // not for ESM import(). We need to transform the import paths during compilation.
+    //
+    // IMPORTANT: We use 'after' transformers because:
+    // 1. 'before' runs before TypeScript resolves modules - changing paths there breaks resolution
+    // 2. 'after' runs after module resolution but before emit - paths are transformed in output only
+    //
+    // We use TRANSFORMED paths here because the output directory structure may differ from source
+    if (module === 'esm' && transformedTsConfigInfo) {
+        logger.debug('Adding path transformer for ESM mode');
+        afterTransformers.push(
+            createPathTransformer({
+                baseUrl: transformedTsConfigInfo.baseUrl,
+                paths: transformedTsConfigInfo.paths,
+            }),
+        );
     }
     }
 
 
-    logger.debug(`tsConfig paths: ${JSON.stringify(tsConfigInfo?.paths, null, 2)}`);
-    logger.debug(`tsConfig baseUrl: ${tsConfigInfo?.baseUrl ?? 'UNKNOWN'}`);
+    // Add the existing transformer for non-entry-point files
+    afterTransformers.push(context => {
+        return sourceFile => {
+            // Only transform files that are not the entry point
+            if (sourceFile.fileName === inputPath) {
+                return sourceFile;
+            }
+            sourceFile.fileName = path.join(outputPath, path.basename(sourceFile.fileName));
+            return sourceFile;
+        };
+    });
 
 
-    // Create a custom transformer to rewrite the output paths
     const customTransformers: ts.CustomTransformers = {
     const customTransformers: ts.CustomTransformers = {
-        after: [
-            context => {
-                return sourceFile => {
-                    // Only transform files that are not the entry point
-                    if (sourceFile.fileName === inputPath) {
-                        return sourceFile;
-                    }
-                    sourceFile.fileName = path.join(outputPath, path.basename(sourceFile.fileName));
-                    return sourceFile;
-                };
-            },
-        ],
+        after: afterTransformers,
     };
     };
 
 
     const program = ts.createProgram([inputPath], compilerOptions);
     const program = ts.createProgram([inputPath], compilerOptions);

+ 167 - 0
packages/dashboard/vite/utils/path-transformer.ts

@@ -0,0 +1,167 @@
+import * as ts from 'typescript';
+
+export interface PathTransformerOptions {
+    baseUrl: string;
+    paths: Record<string, string[]>;
+}
+
+interface PathMatcher {
+    pattern: string;
+    regex: RegExp;
+    targets: string[];
+    hasWildcard: boolean;
+}
+
+/**
+ * Creates a TypeScript custom transformer that rewrites import/export paths
+ * from tsconfig path aliases to their resolved relative paths.
+ *
+ * This is necessary for ESM mode where tsconfig-paths.register() doesn't work
+ * because it only hooks into CommonJS require(), not ESM import().
+ *
+ * The transformer assumes that both the importing file and imported file compile
+ * to the same flat output directory. For complex monorepo setups with nested output
+ * structures, the `pathAdapter.transformTsConfigPathMappings` callback should be
+ * used to adjust paths appropriately.
+ *
+ * Known limitations:
+ * - Only the first path target is used when multiple fallbacks are configured
+ * - `require()` calls via `createRequire` are not transformed
+ */
+export function createPathTransformer(options: PathTransformerOptions): ts.TransformerFactory<ts.SourceFile> {
+    const { paths } = options;
+
+    // Compile the path patterns into matchers
+    const pathMatchers = Object.entries(paths).map(([pattern, targets]) => {
+        const hasWildcard = pattern.includes('*');
+
+        // Escape special regex chars, then replace * with capture group
+        const regexStr: string = pattern
+            .replace(/[.+?^${}()|[\]\\]/g, String.raw`\$&`)
+            .split('*')
+            .join('(.*)');
+
+        const regex = new RegExp('^' + regexStr + '$');
+
+        return { pattern, regex, targets, hasWildcard };
+    });
+
+    return context => {
+        const visitor: ts.Visitor = node => {
+            // Handle import declarations: import { X } from 'module';
+            if (
+                ts.isImportDeclaration(node) &&
+                node.moduleSpecifier &&
+                ts.isStringLiteral(node.moduleSpecifier)
+            ) {
+                const resolvedPath = resolvePathAlias(node.moduleSpecifier.text, pathMatchers);
+                if (resolvedPath) {
+                    return context.factory.updateImportDeclaration(
+                        node,
+                        node.modifiers,
+                        node.importClause,
+                        context.factory.createStringLiteral(resolvedPath),
+                        node.attributes,
+                    );
+                }
+            }
+
+            // Handle export declarations: export { X } from 'module';
+            if (
+                ts.isExportDeclaration(node) &&
+                node.moduleSpecifier &&
+                ts.isStringLiteral(node.moduleSpecifier)
+            ) {
+                const resolvedPath = resolvePathAlias(node.moduleSpecifier.text, pathMatchers);
+                if (resolvedPath) {
+                    return context.factory.updateExportDeclaration(
+                        node,
+                        node.modifiers,
+                        node.isTypeOnly,
+                        node.exportClause,
+                        context.factory.createStringLiteral(resolvedPath),
+                        node.attributes,
+                    );
+                }
+            }
+
+            // Handle dynamic imports: import('module')
+            if (
+                ts.isCallExpression(node) &&
+                node.expression.kind === ts.SyntaxKind.ImportKeyword &&
+                node.arguments.length > 0 &&
+                ts.isStringLiteral(node.arguments[0])
+            ) {
+                const resolvedPath = resolvePathAlias(node.arguments[0].text, pathMatchers);
+                if (resolvedPath) {
+                    return context.factory.updateCallExpression(node, node.expression, node.typeArguments, [
+                        context.factory.createStringLiteral(resolvedPath),
+                        ...node.arguments.slice(1),
+                    ]);
+                }
+            }
+
+            return ts.visitEachChild(node, visitor, context);
+        };
+
+        return sourceFile => ts.visitNode(sourceFile, visitor) as ts.SourceFile;
+    };
+}
+
+/**
+ * Resolves a path alias to its actual path.
+ * Returns undefined if the module specifier doesn't match any path alias.
+ */
+function resolvePathAlias(moduleSpecifier: string, pathMatchers: PathMatcher[]): string | undefined {
+    if (moduleSpecifier.startsWith('.') || moduleSpecifier.startsWith('/')) {
+        return undefined;
+    }
+
+    for (const { regex, targets, hasWildcard } of pathMatchers) {
+        const match = regex.exec(moduleSpecifier);
+        if (match) {
+            const target = targets[0];
+            const resolved = hasWildcard && match[1] ? target.split('*').join(match[1]) : target;
+
+            return normalizeResolvedPath(resolved);
+        }
+    }
+
+    return undefined;
+}
+
+/**
+ * Normalizes a resolved path to a relative path with ./ prefix
+ * and converts TypeScript extensions to JavaScript equivalents.
+ */
+function normalizeResolvedPath(resolved: string): string {
+    // Normalize to relative path with ./ prefix
+    let result = resolved.startsWith('./') ? resolved.substring(2) : resolved;
+    result = `./${result}`;
+    result = result.split('\\').join('/');
+
+    // Convert TypeScript extensions to JavaScript equivalents for ESM
+    return convertExtension(result);
+}
+
+/**
+ * Converts TypeScript extensions to JavaScript equivalents for ESM.
+ * .ts -> .js, .tsx -> .js, .mts -> .mjs, .cts -> .cjs
+ */
+function convertExtension(filePath: string): string {
+    if (filePath.endsWith('.ts') || filePath.endsWith('.tsx')) {
+        return filePath.replace(/\.tsx?$/, '.js');
+    }
+    if (filePath.endsWith('.mts')) {
+        return filePath.replace(/\.mts$/, '.mjs');
+    }
+    if (filePath.endsWith('.cts')) {
+        return filePath.replace(/\.cts$/, '.cjs');
+    }
+    // No extension - assume directory import, add /index.js
+    if (!/\.\w+$/.test(filePath)) {
+        return `${filePath}/index.js`;
+    }
+    // Files with other extensions (.json, .js, etc.) are left as-is
+    return filePath;
+}