branch:
transformer.ts
10897 bytesRaw
import { transform } from "sucrase";
import { parseImports, resolveModule } from "./resolver";
import type { CreateWorkerResult, Files, Modules } from "./types";

export interface TransformResult {
  code: string;
  sourceMap?: string;
}

export interface TransformOptions {
  /**
   * Source file path (for source maps and error messages)
   */
  filePath: string;

  /**
   * Whether to generate source maps
   */
  sourceMap?: boolean;

  /**
   * Whether to preserve JSX (don't transform to createElement calls)
   */
  preserveJsx?: boolean;

  /**
   * JSX runtime ('automatic' for new JSX transform, 'classic' for React.createElement)
   */
  jsxRuntime?: "automatic" | "classic" | "preserve";

  /**
   * JSX import source for automatic runtime (default: 'react')
   */
  jsxImportSource?: string;

  /**
   * Whether this is a production build
   */
  production?: boolean;
}

/**
 * Transform TypeScript/JSX code to JavaScript using Sucrase.
 *
 * Sucrase is a super-fast TypeScript transformer that:
 * - Strips type annotations
 * - Transforms JSX
 * - Is ~20x faster than Babel
 * - Works in any JS environment (no WASM needed)
 *
 * @param code - Source code to transform
 * @param options - Transform options
 * @returns Transformed code
 */
export function transformCode(
  code: string,
  options: TransformOptions
): TransformResult {
  const {
    filePath,
    sourceMap = false,
    jsxRuntime = "automatic",
    jsxImportSource = "react",
    production = false
  } = options;

  const transforms: Array<"typescript" | "jsx" | "flow"> = [];

  // Determine transforms based on file extension
  if (isTypeScriptFile(filePath)) {
    transforms.push("typescript");
  }

  if (isJsxFile(filePath)) {
    if (jsxRuntime !== "preserve") {
      transforms.push("jsx");
    }
  }

  if (transforms.length === 0) {
    // No transforms needed, return as-is
    return { code };
  }

  const transformOptions: Parameters<typeof transform>[1] = {
    transforms,
    filePath,
    jsxRuntime,
    jsxImportSource,
    production,
    // Keep ESM imports/exports as-is
    preserveDynamicImport: true,
    // Disable ES transforms since Workers support modern JS
    disableESTransforms: true
  };

  if (sourceMap) {
    transformOptions.sourceMapOptions = {
      compiledFilename: filePath.replace(/\.(tsx?|mts)$/, ".js")
    };
  }

  const result = transform(code, transformOptions);

  if (result.sourceMap) {
    return {
      code: result.code,
      sourceMap: JSON.stringify(result.sourceMap)
    };
  }
  return { code: result.code };
}

/**
 * Check if a file path is a TypeScript file
 */
export function isTypeScriptFile(filePath: string): boolean {
  return /\.(ts|tsx|mts)$/.test(filePath);
}

/**
 * Check if a file path is a JSX file
 */
export function isJsxFile(filePath: string): boolean {
  return /\.(jsx|tsx)$/.test(filePath);
}

/**
 * Check if a file path is any JavaScript/TypeScript file
 */
export function isJavaScriptFile(filePath: string): boolean {
  return /\.(js|jsx|ts|tsx|mjs|mts)$/.test(filePath);
}

/**
 * Get the output path for a transformed file
 */
export function getOutputPath(filePath: string): string {
  // .ts -> .js, .tsx -> .js, .mts -> .mjs
  return filePath.replace(/\.tsx?$/, ".js").replace(/\.mts$/, ".mjs");
}

/**
 * Transform all files and resolve their dependencies.
 * This produces multiple modules instead of a single bundle.
 */
export async function transformAndResolve(
  files: Files,
  entryPoint: string,
  externals: string[]
): Promise<CreateWorkerResult> {
  const modules: Modules = {};
  const warnings: string[] = [];
  const processed = new Set<string>();
  const toProcess = [entryPoint];

  // Map from source path to output path
  const pathMap = new Map<string, string>();

  // First pass: collect all files and their output paths
  while (toProcess.length > 0) {
    const filePath = toProcess.pop();
    if (!filePath || processed.has(filePath)) continue;
    processed.add(filePath);

    const content = files[filePath];
    if (content === undefined) {
      warnings.push(`File not found: ${filePath}`);
      continue;
    }

    // Calculate output path
    const outputPath = isTypeScriptFile(filePath)
      ? getOutputPath(filePath)
      : filePath;
    pathMap.set(filePath, outputPath);

    // Handle non-JS files
    if (!isJavaScriptFile(filePath)) {
      if (filePath.endsWith(".json")) {
        try {
          modules[filePath] = { json: JSON.parse(content) };
        } catch {
          warnings.push(`Failed to parse JSON file: ${filePath}`);
        }
      } else {
        // Include as text
        modules[filePath] = { text: content };
      }
      continue;
    }

    // Parse imports and queue them for processing
    const imports = parseImports(content);
    for (const specifier of imports) {
      // Skip external modules
      if (
        externals.includes(specifier) ||
        externals.some(
          (e) => specifier.startsWith(`${e}/`) || specifier.startsWith(e)
        )
      ) {
        continue;
      }

      try {
        const resolved = resolveModule(specifier, {
          files,
          importer: filePath
        });

        if (!resolved.external && !processed.has(resolved.path)) {
          toProcess.push(resolved.path);
        }
      } catch (error) {
        warnings.push(
          `Failed to resolve '${specifier}' from ${filePath}: ${error instanceof Error ? error.message : error}`
        );
      }
    }
  }

  // Second pass: transform files and rewrite imports
  for (const [sourcePath, outputPath] of pathMap) {
    const content = files[sourcePath];
    if (content === undefined || !isJavaScriptFile(sourcePath)) continue;

    let transformedCode: string;

    if (isTypeScriptFile(sourcePath)) {
      try {
        const result = transformCode(content, {
          filePath: sourcePath
        });
        transformedCode = result.code;
      } catch (error) {
        warnings.push(
          `Failed to transform ${sourcePath}: ${error instanceof Error ? error.message : error}`
        );
        continue;
      }
    } else {
      transformedCode = content;
    }

    // Rewrite imports to use the full output paths
    transformedCode = rewriteImports(
      transformedCode,
      sourcePath,
      files,
      pathMap,
      externals
    );

    // Add to output modules
    modules[outputPath] = transformedCode;
  }

  // Calculate the main module path (transformed entry point)
  const mainModule = isTypeScriptFile(entryPoint)
    ? getOutputPath(entryPoint)
    : entryPoint;

  if (warnings.length > 0) {
    return { mainModule, modules, warnings };
  }
  return { mainModule, modules };
}

/**
 * Rewrite import specifiers to use full output paths.
 * This is necessary because the Worker Loader expects imports to match registered module names.
 */
function rewriteImports(
  code: string,
  importer: string,
  files: Files,
  pathMap: Map<string, string>,
  externals: string[]
): string {
  // Match import/export statements with string specifiers
  // Handles: import x from 'y', import { x } from 'y', import 'y', export { x } from 'y', export * from 'y'
  const importExportRegex =
    /(import\s+(?:[\w*{}\s,]+\s+from\s+)?|export\s+(?:[\w*{}\s,]+\s+)?from\s+)(['"])([^'"]+)\2/g;

  // Get importer's output path to use as the base for resolving
  const importerOutputPath = pathMap.get(importer) ?? importer;

  return code.replace(
    importExportRegex,
    (match, prefix: string, quote: string, specifier: string) => {
      // Skip external modules
      if (
        externals.includes(specifier) ||
        externals.some(
          (e) => specifier.startsWith(`${e}/`) || specifier.startsWith(e)
        )
      ) {
        return match;
      }

      // Skip non-relative imports that aren't in our files (bare imports to npm packages)
      if (!specifier.startsWith(".") && !specifier.startsWith("/")) {
        // Try to resolve it - if it resolves to node_modules, rewrite the path
        try {
          const resolved = resolveModule(specifier, {
            files,
            importer
          });

          if (resolved.external) {
            return match;
          }

          // Get the output path for the resolved module
          const resolvedOutputPath =
            pathMap.get(resolved.path) ?? resolved.path;

          // For node_modules imports, use the full path
          if (resolved.path.startsWith("node_modules/")) {
            return `${prefix}${quote}/${resolvedOutputPath}${quote}`;
          }

          // Calculate relative path for non-node_modules
          const relativePath = calculateRelativePath(
            importerOutputPath,
            resolvedOutputPath
          );
          return `${prefix}${quote}${relativePath}${quote}`;
        } catch {
          // Resolution failed, keep original
          return match;
        }
      }

      try {
        const resolved = resolveModule(specifier, {
          files,
          importer
        });

        if (resolved.external) {
          return match;
        }

        // Get the output path for the resolved module
        const resolvedOutputPath = pathMap.get(resolved.path) ?? resolved.path;

        // Calculate the relative path from the importer's output location to the resolved output
        const relativePath = calculateRelativePath(
          importerOutputPath,
          resolvedOutputPath
        );

        // Return the rewritten import with the relative output path
        return `${prefix}${quote}${relativePath}${quote}`;
      } catch {
        // If resolution fails, keep the original
        return match;
      }
    }
  );
}

/**
 * Calculate relative path from one file to another.
 */
function calculateRelativePath(from: string, to: string): string {
  const fromDir = getDirectory(from);
  const toDir = getDirectory(to);
  const toFile = to.split("/").pop() ?? to;

  if (fromDir === toDir) {
    // Same directory
    return `./${toFile}`;
  }

  const fromParts = fromDir ? fromDir.split("/") : [];
  const toParts = toDir ? toDir.split("/") : [];

  // Find common prefix
  let commonLength = 0;
  while (
    commonLength < fromParts.length &&
    commonLength < toParts.length &&
    fromParts[commonLength] === toParts[commonLength]
  ) {
    commonLength++;
  }

  // Calculate relative path
  const upCount = fromParts.length - commonLength;
  const downParts = toParts.slice(commonLength);

  let relativePath = "";
  if (upCount === 0) {
    relativePath = "./";
  } else {
    relativePath = "../".repeat(upCount);
  }

  if (downParts.length > 0) {
    relativePath += `${downParts.join("/")}/`;
  }

  return relativePath + toFile;
}

function getDirectory(filePath: string): string {
  const lastSlash = filePath.lastIndexOf("/");
  if (lastSlash === -1) {
    return "";
  }
  return filePath.slice(0, lastSlash);
}