TsconfigPathsPlugin.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585
  1. /*
  2. MIT License http://www.opensource.org/licenses/mit-license.php
  3. Author Natsu @xiaoxiaojx
  4. */
  5. "use strict";
  6. const { aliasResolveHandler } = require("./AliasUtils");
  7. const { modulesResolveHandler } = require("./ModulesUtils");
  8. const { readJson } = require("./util/fs");
  9. const {
  10. PathType: _PathType,
  11. cachedDirname: dirname,
  12. cachedJoin: join,
  13. isSubPath,
  14. normalize,
  15. } = require("./util/path");
  16. /** @typedef {import("./Resolver")} Resolver */
  17. /** @typedef {import("./Resolver").ResolveStepHook} ResolveStepHook */
  18. /** @typedef {import("./AliasUtils").AliasOption} AliasOption */
  19. /** @typedef {import("./Resolver").ResolveRequest} ResolveRequest */
  20. /** @typedef {import("./Resolver").ResolveContext} ResolveContext */
  21. /** @typedef {import("./Resolver").FileSystem} FileSystem */
  22. /** @typedef {import("./Resolver").TsconfigPathsData} TsconfigPathsData */
  23. /** @typedef {import("./Resolver").TsconfigPathsMap} TsconfigPathsMap */
  24. /** @typedef {import("./ResolverFactory").TsconfigOptions} TsconfigOptions */
  25. /**
  26. * @typedef {object} TsconfigCompilerOptions
  27. * @property {string=} baseUrl Base URL for resolving paths
  28. * @property {{ [key: string]: string[] }=} paths TypeScript paths mapping
  29. */
  30. /**
  31. * @typedef {object} TsconfigReference
  32. * @property {string} path Path to the referenced project
  33. */
  34. /**
  35. * @typedef {object} Tsconfig
  36. * @property {TsconfigCompilerOptions=} compilerOptions Compiler options
  37. * @property {string | string[]=} extends Extended configuration paths
  38. * @property {TsconfigReference[]=} references Project references
  39. */
  40. const DEFAULT_CONFIG_FILE = "tsconfig.json";
  41. /**
  42. * @param {string} pattern Path pattern
  43. * @returns {number} Length of the prefix
  44. */
  45. function getPrefixLength(pattern) {
  46. const prefixLength = pattern.indexOf("*");
  47. if (prefixLength === -1) {
  48. return pattern.length;
  49. }
  50. return pattern.slice(0, Math.max(0, prefixLength)).length;
  51. }
  52. /**
  53. * Sort path patterns.
  54. * If a module name can be matched with multiple patterns then pattern with the longest prefix will be picked.
  55. * @param {string[]} arr Array of path patterns
  56. * @returns {string[]} Array of path patterns sorted by longest prefix
  57. */
  58. function sortByLongestPrefix(arr) {
  59. return [...arr].sort((a, b) => getPrefixLength(b) - getPrefixLength(a));
  60. }
  61. /**
  62. * Merge two tsconfig objects
  63. * @param {Tsconfig | null} base base config
  64. * @param {Tsconfig | null} config config to merge
  65. * @returns {Tsconfig} merged config
  66. */
  67. function mergeTsconfigs(base, config) {
  68. base = base || {};
  69. config = config || {};
  70. return {
  71. ...base,
  72. ...config,
  73. compilerOptions: {
  74. .../** @type {TsconfigCompilerOptions} */ (base.compilerOptions),
  75. .../** @type {TsconfigCompilerOptions} */ (config.compilerOptions),
  76. },
  77. };
  78. }
  79. /**
  80. * Substitute ${configDir} template variable in path
  81. * @param {string} pathValue the path value
  82. * @param {string} configDir the config directory
  83. * @returns {string} the path with substituted template
  84. */
  85. function substituteConfigDir(pathValue, configDir) {
  86. return pathValue.replace(/\$\{configDir\}/g, configDir);
  87. }
  88. /**
  89. * Convert tsconfig paths to resolver options
  90. * @param {string} configDir Config file directory
  91. * @param {{ [key: string]: string[] }} paths TypeScript paths mapping
  92. * @param {string=} baseUrl Base URL for resolving paths (relative to configDir)
  93. * @returns {TsconfigPathsData} the resolver options
  94. */
  95. function tsconfigPathsToResolveOptions(configDir, paths, baseUrl) {
  96. // Calculate absolute base URL
  97. const absoluteBaseUrl = !baseUrl ? configDir : join(configDir, baseUrl);
  98. /** @type {string[]} */
  99. const sortedKeys = sortByLongestPrefix(Object.keys(paths));
  100. /** @type {AliasOption[]} */
  101. const alias = [];
  102. /** @type {string[]} */
  103. const modules = [];
  104. for (const pattern of sortedKeys) {
  105. const mappings = paths[pattern];
  106. // Substitute ${configDir} in path mappings
  107. const absolutePaths = mappings.map((mapping) => {
  108. const substituted = substituteConfigDir(mapping, configDir);
  109. return join(absoluteBaseUrl, substituted);
  110. });
  111. if (absolutePaths.length > 0) {
  112. if (pattern === "*") {
  113. modules.push(
  114. ...absolutePaths
  115. .map((dir) => {
  116. if (/[/\\]\*$/.test(dir)) {
  117. return dir.replace(/[/\\]\*$/, "");
  118. }
  119. return "";
  120. })
  121. .filter(Boolean),
  122. );
  123. } else {
  124. alias.push({ name: pattern, alias: absolutePaths });
  125. }
  126. }
  127. }
  128. if (absoluteBaseUrl && !modules.includes(absoluteBaseUrl)) {
  129. modules.push(absoluteBaseUrl);
  130. }
  131. return {
  132. alias,
  133. modules,
  134. };
  135. }
  136. /**
  137. * Get the base context for the current project
  138. * @param {string} context the context
  139. * @param {string=} baseUrl base URL for resolving paths
  140. * @returns {string} the base context
  141. */
  142. function getAbsoluteBaseUrl(context, baseUrl) {
  143. return !baseUrl ? context : join(context, baseUrl);
  144. }
  145. module.exports = class TsconfigPathsPlugin {
  146. /**
  147. * @param {true | string | TsconfigOptions} configFileOrOptions tsconfig file path or options object
  148. */
  149. constructor(configFileOrOptions) {
  150. if (
  151. typeof configFileOrOptions === "object" &&
  152. configFileOrOptions !== null
  153. ) {
  154. // Options object format
  155. this.configFile = configFileOrOptions.configFile || DEFAULT_CONFIG_FILE;
  156. /** @type {string[] | "auto"} */
  157. if (Array.isArray(configFileOrOptions.references)) {
  158. /** @type {TsconfigReference[] | "auto"} */
  159. this.references = configFileOrOptions.references.map((ref) => ({
  160. path: ref,
  161. }));
  162. } else if (configFileOrOptions.references === "auto") {
  163. this.references = "auto";
  164. } else {
  165. this.references = [];
  166. }
  167. /** @type {string | undefined} */
  168. this.baseUrl = configFileOrOptions.baseUrl;
  169. } else {
  170. this.configFile =
  171. configFileOrOptions === true
  172. ? DEFAULT_CONFIG_FILE
  173. : /** @type {string} */ (configFileOrOptions);
  174. /** @type {TsconfigReference[] | "auto"} */
  175. this.references = [];
  176. /** @type {string | undefined} */
  177. this.baseUrl = undefined;
  178. }
  179. }
  180. /**
  181. * @param {Resolver} resolver the resolver
  182. * @returns {void}
  183. */
  184. apply(resolver) {
  185. const aliasTarget = resolver.ensureHook("internal-resolve");
  186. const moduleTarget = resolver.ensureHook("module");
  187. resolver
  188. .getHook("raw-resolve")
  189. .tapAsync(
  190. "TsconfigPathsPlugin",
  191. async (request, resolveContext, callback) => {
  192. try {
  193. const tsconfigPathsMap = await this._getTsconfigPathsMap(
  194. resolver,
  195. request,
  196. resolveContext,
  197. );
  198. if (!tsconfigPathsMap) return callback();
  199. const selectedData = this._selectPathsDataForContext(
  200. request.path,
  201. tsconfigPathsMap,
  202. );
  203. if (!selectedData) return callback();
  204. aliasResolveHandler(
  205. resolver,
  206. selectedData.alias,
  207. aliasTarget,
  208. request,
  209. resolveContext,
  210. callback,
  211. );
  212. } catch (err) {
  213. callback(/** @type {Error} */ (err));
  214. }
  215. },
  216. );
  217. resolver
  218. .getHook("raw-module")
  219. .tapAsync(
  220. "TsconfigPathsPlugin",
  221. async (request, resolveContext, callback) => {
  222. try {
  223. const tsconfigPathsMap = await this._getTsconfigPathsMap(
  224. resolver,
  225. request,
  226. resolveContext,
  227. );
  228. if (!tsconfigPathsMap) return callback();
  229. const selectedData = this._selectPathsDataForContext(
  230. request.path,
  231. tsconfigPathsMap,
  232. );
  233. if (!selectedData) return callback();
  234. modulesResolveHandler(
  235. resolver,
  236. selectedData.modules,
  237. moduleTarget,
  238. request,
  239. resolveContext,
  240. callback,
  241. );
  242. } catch (err) {
  243. callback(/** @type {Error} */ (err));
  244. }
  245. },
  246. );
  247. }
  248. /**
  249. * Get TsconfigPathsMap for the request (with caching)
  250. * @param {Resolver} resolver the resolver
  251. * @param {ResolveRequest} request the request
  252. * @param {ResolveContext} resolveContext the resolve context
  253. * @returns {Promise<TsconfigPathsMap | null>} the tsconfig paths map or null
  254. */
  255. async _getTsconfigPathsMap(resolver, request, resolveContext) {
  256. if (typeof request.tsconfigPathsMap === "undefined") {
  257. try {
  258. const absTsconfigPath = join(
  259. request.path || process.cwd(),
  260. this.configFile,
  261. );
  262. const result = await this._loadTsconfigPathsMap(
  263. resolver.fileSystem,
  264. absTsconfigPath,
  265. );
  266. request.tsconfigPathsMap = result;
  267. } catch (err) {
  268. request.tsconfigPathsMap = null;
  269. throw err;
  270. }
  271. }
  272. if (!request.tsconfigPathsMap) {
  273. return null;
  274. }
  275. for (const fileDependency of request.tsconfigPathsMap.fileDependencies) {
  276. if (resolveContext.fileDependencies) {
  277. resolveContext.fileDependencies.add(fileDependency);
  278. }
  279. }
  280. return request.tsconfigPathsMap;
  281. }
  282. /**
  283. * Load tsconfig.json and build complete TsconfigPathsMap
  284. * Includes main project paths and all referenced projects
  285. * @param {FileSystem} fileSystem the file system
  286. * @param {string} absTsconfigPath absolute path to tsconfig.json
  287. * @returns {Promise<TsconfigPathsMap>} the complete tsconfig paths map
  288. */
  289. async _loadTsconfigPathsMap(fileSystem, absTsconfigPath) {
  290. /** @type {Set<string>} */
  291. const fileDependencies = new Set();
  292. const config = await this._loadTsconfig(
  293. fileSystem,
  294. absTsconfigPath,
  295. fileDependencies,
  296. );
  297. const compilerOptions = config.compilerOptions || {};
  298. const mainContext = dirname(absTsconfigPath);
  299. const baseUrl =
  300. this.baseUrl !== undefined ? this.baseUrl : compilerOptions.baseUrl;
  301. const main = tsconfigPathsToResolveOptions(
  302. mainContext,
  303. compilerOptions.paths || {},
  304. baseUrl,
  305. );
  306. /** @type {{ [baseUrl: string]: TsconfigPathsData }} */
  307. const refs = {};
  308. let referencesToUse = null;
  309. if (this.references === "auto") {
  310. referencesToUse = config.references;
  311. } else if (Array.isArray(this.references)) {
  312. referencesToUse = this.references;
  313. }
  314. if (Array.isArray(referencesToUse)) {
  315. await this._loadTsconfigReferences(
  316. fileSystem,
  317. mainContext,
  318. referencesToUse,
  319. fileDependencies,
  320. refs,
  321. );
  322. }
  323. return { main, mainContext, refs, fileDependencies };
  324. }
  325. /**
  326. * Select the correct TsconfigPathsData based on request.path (context-aware)
  327. * Matches the behavior of tsconfig-paths-webpack-plugin
  328. * @param {string | false} requestPath the request path
  329. * @param {TsconfigPathsMap} tsconfigPathsMap the tsconfig paths map
  330. * @returns {TsconfigPathsData | null} the selected paths data
  331. */
  332. _selectPathsDataForContext(requestPath, tsconfigPathsMap) {
  333. const { main, mainContext, refs } = tsconfigPathsMap;
  334. if (!requestPath) {
  335. return main;
  336. }
  337. // Combine main and refs into a single map: context path -> TsconfigPathsData
  338. const allContexts = {
  339. [mainContext]: main,
  340. ...refs,
  341. };
  342. let longestMatch = null;
  343. let longestMatchLength = 0;
  344. for (const [context, data] of Object.entries(allContexts)) {
  345. if (context === requestPath) {
  346. return data;
  347. }
  348. if (
  349. isSubPath(context, requestPath) &&
  350. context.length > longestMatchLength
  351. ) {
  352. longestMatch = data;
  353. longestMatchLength = context.length;
  354. }
  355. }
  356. if (longestMatch) {
  357. return longestMatch;
  358. }
  359. return null;
  360. }
  361. /**
  362. * Load tsconfig from extends path
  363. * @param {FileSystem} fileSystem the file system
  364. * @param {string} configFilePath current config file path
  365. * @param {string} extendedConfigValue extends value
  366. * @param {Set<string>} fileDependencies the file dependencies
  367. * @param {Set<string>} visitedConfigPaths config paths being loaded (for circular extends detection)
  368. * @returns {Promise<Tsconfig>} the extended tsconfig
  369. */
  370. async _loadTsconfigFromExtends(
  371. fileSystem,
  372. configFilePath,
  373. extendedConfigValue,
  374. fileDependencies,
  375. visitedConfigPaths,
  376. ) {
  377. const currentDir = dirname(configFilePath);
  378. // Substitute ${configDir} in extends path
  379. extendedConfigValue = substituteConfigDir(extendedConfigValue, currentDir);
  380. if (
  381. typeof extendedConfigValue === "string" &&
  382. !extendedConfigValue.includes(".json")
  383. ) {
  384. extendedConfigValue += ".json";
  385. }
  386. let extendedConfigPath = join(currentDir, extendedConfigValue);
  387. const exists = await new Promise((resolve) => {
  388. fileSystem.readFile(extendedConfigPath, (err) => {
  389. resolve(!err);
  390. });
  391. });
  392. if (!exists && extendedConfigValue.includes("/")) {
  393. extendedConfigPath = join(
  394. currentDir,
  395. normalize(`node_modules/${extendedConfigValue}`),
  396. );
  397. }
  398. const config = await this._loadTsconfig(
  399. fileSystem,
  400. extendedConfigPath,
  401. fileDependencies,
  402. visitedConfigPaths,
  403. );
  404. const compilerOptions = config.compilerOptions || { baseUrl: undefined };
  405. if (compilerOptions.baseUrl) {
  406. const extendsDir = dirname(extendedConfigValue);
  407. compilerOptions.baseUrl = getAbsoluteBaseUrl(
  408. extendsDir,
  409. compilerOptions.baseUrl,
  410. );
  411. }
  412. delete config.references;
  413. return /** @type {Tsconfig} */ (config);
  414. }
  415. /**
  416. * Load referenced tsconfig projects and store in referenceMatchMap
  417. * Simple implementation matching tsconfig-paths-webpack-plugin:
  418. * Just load each reference and store independently
  419. * @param {FileSystem} fileSystem the file system
  420. * @param {string} context the context
  421. * @param {TsconfigReference[]} references array of references
  422. * @param {Set<string>} fileDependencies the file dependencies
  423. * @param {{ [baseUrl: string]: TsconfigPathsData }} referenceMatchMap the map to populate
  424. * @returns {Promise<void>}
  425. */
  426. async _loadTsconfigReferences(
  427. fileSystem,
  428. context,
  429. references,
  430. fileDependencies,
  431. referenceMatchMap,
  432. ) {
  433. for (const ref of references) {
  434. // Substitute ${configDir} in reference path
  435. const refPath = substituteConfigDir(ref.path, context);
  436. const refConfigPath = join(join(context, refPath), DEFAULT_CONFIG_FILE);
  437. try {
  438. const refConfig = await this._loadTsconfig(
  439. fileSystem,
  440. refConfigPath,
  441. fileDependencies,
  442. );
  443. if (refConfig.compilerOptions && refConfig.compilerOptions.paths) {
  444. const refContext = dirname(refConfigPath);
  445. referenceMatchMap[refContext] = tsconfigPathsToResolveOptions(
  446. refContext,
  447. refConfig.compilerOptions.paths || {},
  448. refConfig.compilerOptions.baseUrl,
  449. );
  450. }
  451. if (this.references === "auto" && Array.isArray(refConfig.references)) {
  452. await this._loadTsconfigReferences(
  453. fileSystem,
  454. dirname(refConfigPath),
  455. refConfig.references,
  456. fileDependencies,
  457. referenceMatchMap,
  458. );
  459. }
  460. } catch (_err) {
  461. continue;
  462. }
  463. }
  464. }
  465. /**
  466. * Load tsconfig.json with extends support
  467. * @param {FileSystem} fileSystem the file system
  468. * @param {string} configFilePath absolute path to tsconfig.json
  469. * @param {Set<string>} fileDependencies the file dependencies
  470. * @param {Set<string>=} visitedConfigPaths config paths being loaded (for circular extends detection)
  471. * @returns {Promise<Tsconfig>} the merged tsconfig
  472. */
  473. async _loadTsconfig(
  474. fileSystem,
  475. configFilePath,
  476. fileDependencies,
  477. visitedConfigPaths = new Set(),
  478. ) {
  479. if (visitedConfigPaths.has(configFilePath)) {
  480. return /** @type {Tsconfig} */ ({});
  481. }
  482. visitedConfigPaths.add(configFilePath);
  483. const config = await readJson(fileSystem, configFilePath, {
  484. stripComments: true,
  485. });
  486. fileDependencies.add(configFilePath);
  487. let result = config;
  488. const extendedConfig = config.extends;
  489. if (extendedConfig) {
  490. let base;
  491. if (Array.isArray(extendedConfig)) {
  492. base = {};
  493. for (const extendedConfigElement of extendedConfig) {
  494. const extendedTsconfig = await this._loadTsconfigFromExtends(
  495. fileSystem,
  496. configFilePath,
  497. extendedConfigElement,
  498. fileDependencies,
  499. visitedConfigPaths,
  500. );
  501. base = mergeTsconfigs(base, extendedTsconfig);
  502. }
  503. } else {
  504. base = await this._loadTsconfigFromExtends(
  505. fileSystem,
  506. configFilePath,
  507. extendedConfig,
  508. fileDependencies,
  509. visitedConfigPaths,
  510. );
  511. }
  512. result = /** @type {Tsconfig} */ (mergeTsconfigs(base, config));
  513. }
  514. return result;
  515. }
  516. };