@ts-bridge/cli
Version:
Bridge the gap between ES modules and CommonJS modules with an easy-to-use alternative to `tsc`.
229 lines (228 loc) • 9.24 kB
JavaScript
import { resolve } from '@ts-bridge/resolver';
import chalk from 'chalk';
import { init, parse } from 'cjs-module-lexer';
import { resolve as resolvePath, extname } from 'path';
import { pathToFileURL } from 'url';
import { warn } from './logging.js';
// This initialises `cjs-module-lexer` so that we can parse CommonJS modules
// quickly using the WASM binary.
await init();
// The first entry is an empty string, which is used for the base package name.
const DEFAULT_EXTENSIONS = ['', '.js', '.cjs', '.mjs', '.json'];
const TYPESCRIPT_EXTENSIONS = ['.ts', '.tsx', '.d.ts'];
const SOURCE_EXTENSIONS_REGEX = /\.(js|jsx|cjs|mjs|ts|tsx)$/u;
/**
* Check if a specifier is relative.
*
* @param specifier - The specifier to check.
* @returns Whether the specifier is relative.
*/
export function isRelative(specifier) {
return specifier.startsWith('.');
}
/**
* Resolve a package specifier to a file in a package. This function will try to
* resolve the package specifier to a file in the package's directory.
*
* @param packageSpecifier - The specifier for the package.
* @param parentUrl - The URL of the parent module.
* @param system - The TypeScript system.
* @param extensions - The extensions to use for resolving the package.
* @returns The resolved package specifier, or `null` if the package could not
* be resolved.
*/
export function resolvePackageSpecifier(packageSpecifier, parentUrl, system, extensions = DEFAULT_EXTENSIONS) {
// We check for `/index.js` as well, to support packages without a `main`
// entry in their `package.json`.
for (const specifier of [packageSpecifier, `${packageSpecifier}/index`]) {
for (const extension of extensions) {
try {
const { format, path } = resolve(`${specifier}${extension}`, pathToFileURL(parentUrl), getFileSystemFromTypeScript(system));
return {
specifier: `${specifier}${extension}`,
path,
format,
};
}
catch {
// no-op
}
}
}
return null;
}
/**
* Resolve a relative package specifier to a file in the package. This function
* will try to resolve the package specifier to a file in the package's
* directory.
*
* @param packageSpecifier - The specifier for the package.
* @param parentUrl - The URL of the parent module.
* @param system - The TypeScript system.
* @param extensions - The extensions to use for resolving the package.
* @returns The resolved package specifier, or `null` if the package could not
* be resolved.
*/
export function resolveRelativePackageSpecifier(packageSpecifier, parentUrl, system, extensions = TYPESCRIPT_EXTENSIONS) {
const basePath = resolvePath(parentUrl, '..', packageSpecifier);
if (system.directoryExists(basePath)) {
return resolveRelativePackageSpecifier(`${packageSpecifier}/index`, parentUrl, system);
}
const packageSpecifierWithoutExtension = packageSpecifier.replace(extname(packageSpecifier), '');
for (const specifier of [
packageSpecifier,
packageSpecifierWithoutExtension,
]) {
const resolution = resolvePackageSpecifier(specifier, parentUrl, system, [
...extensions,
...DEFAULT_EXTENSIONS,
]);
if (resolution) {
return resolution;
}
}
return null;
}
/**
* Resolve a module.
*
* @param packageSpecifier - The specifier for the module.
* @param parentUrl - The URL of the parent module.
* @param system - The TypeScript system.
* @param extensions - The extensions to use for resolving the module.
* @returns The resolved module, or `null` if the module could not be resolved.
*/
function resolveModule(packageSpecifier, parentUrl, system, extensions) {
if (isRelative(packageSpecifier)) {
return resolveRelativePackageSpecifier(packageSpecifier, parentUrl, system, extensions);
}
return resolvePackageSpecifier(packageSpecifier, parentUrl, system, extensions);
}
/**
* Replace the extension of a path.
*
* @param path - The path to replace the extension of.
* @param extension - The new extension.
* @returns The path with the new extension.
*/
export function replaceExtension(path, extension) {
return path.replace(SOURCE_EXTENSIONS_REGEX, extension);
}
/**
* Get the path to a module.
*
* @param options - The options for resolving the module.
* @param options.packageSpecifier - The specifier for the module.
* @param options.extension - The extension to use for relative source paths.
* @param options.parentUrl - The URL of the parent module.
* @param options.system - The TypeScript system.
* @param options.verbose - Whether to show verbose output.
* @returns The path to the module, or the original specifier if the module
* could not be resolved.
*/
export function getModulePath({ packageSpecifier, extension, parentUrl, system, verbose, }) {
const resolution = resolveModule(packageSpecifier, parentUrl, system);
if (!resolution) {
verbose &&
warn(`Could not resolve module: ${chalk.bold(`"${packageSpecifier}"`)}. This means that TS Bridge will not update the import path, and the module may not be resolved correctly in some cases.`);
return packageSpecifier;
}
if (isRelative(packageSpecifier)) {
return replaceExtension(resolution.specifier, extension);
}
return resolution.specifier;
}
/**
* Get a {@link FileSystemInterface} from a TypeScript system. This is used
* for module resolution.
*
* @param system - The TypeScript system.
* @returns The file system interface.
*/
export function getFileSystemFromTypeScript(system) {
return {
isFile: system.fileExists.bind(system),
isDirectory: system.directoryExists.bind(system),
readFile(path) {
const contents = system.readFile(path);
if (contents === undefined) {
throw new Error(`File not found: "${path}".`);
}
return contents;
},
readBytes(path, length) {
const contents = system.readFile(path);
if (contents === undefined) {
throw new Error(`File not found: "${path}".`);
}
// TypeScript does not support reading a file as bytes, so we convert the
// contents to a byte array manually. This is a bit hacky, but it should
// work for most cases.
const buffer = new Uint8Array(length);
for (let index = 0; index < length; index++) {
buffer[index] = contents.charCodeAt(index);
}
return buffer;
},
};
}
/**
* Get the module type for a given package specifier.
*
* @param packageSpecifier - The specifier for the package.
* @param system - The TypeScript system.
* @param parentUrl - The URL of the parent module.
* @returns The module type for the package.
*/
export function getModuleType(packageSpecifier, system, parentUrl) {
const resolution = resolveModule(packageSpecifier, parentUrl, system);
return resolution?.format ?? null;
}
/**
* Check if a package specifier is a CommonJS package.
*
* @param packageSpecifier - The specifier for the package.
* @param system - The TypeScript system.
* @param parentUrl - The URL of the parent module.
* @returns Whether the package is a CommonJS package.
*/
export function isCommonJs(packageSpecifier, system, parentUrl) {
if (isRelative(packageSpecifier)) {
return false;
}
return getModuleType(packageSpecifier, system, parentUrl) === 'commonjs';
}
/**
* Get the exports for a CommonJS package. This uses `cjs-module-lexer` to parse
* the CommonJS module and extract the exports, which matches the behaviour of
* Node.js. This function will return an empty array if the package is not a
* CommonJS package, or if the package could not be resolved.
*
* @param packageSpecifier - The specifier for the package.
* @param system - The TypeScript system.
* @param parentUrl - The URL of the parent module.
* @returns The exports for the CommonJS package.
* @throws If the package could not be parsed.
*/
export function getCommonJsExports(packageSpecifier, system, parentUrl) {
const relative = isRelative(packageSpecifier);
const resolution = resolveModule(packageSpecifier, parentUrl, system,
// We assume that if the packageSpecifier is relative, we are traversing a specific file looking for CJS imports
relative ? DEFAULT_EXTENSIONS : undefined);
if (!resolution || resolution.format !== 'commonjs') {
return new Set();
}
const { path } = resolution;
const code = system.readFile(path);
if (!code) {
return new Set();
}
const { exports, reexports } = parse(code);
// Re-exports are paths to exports that must be resolved themselves
const resolvedReexports = reexports.reduce((accumulator, reexport) => {
const exportSet = getCommonJsExports(reexport, system, path);
exportSet.forEach((exportName) => accumulator.add(exportName));
return accumulator;
}, new Set());
return new Set([...exports, ...resolvedReexports]);
}