TsconfigPathsPlugin.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606
  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 prefixLength;
  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. const allContexts =
  324. /** @type {{ [context: string]: TsconfigPathsData }} */ ({
  325. [mainContext]: main,
  326. ...refs,
  327. });
  328. return { main, mainContext, refs, allContexts, fileDependencies };
  329. }
  330. /**
  331. * Select the correct TsconfigPathsData based on request.path (context-aware)
  332. * Matches the behavior of tsconfig-paths-webpack-plugin
  333. * @param {string | false} requestPath the request path
  334. * @param {TsconfigPathsMap} tsconfigPathsMap the tsconfig paths map
  335. * @returns {TsconfigPathsData | null} the selected paths data
  336. */
  337. _selectPathsDataForContext(requestPath, tsconfigPathsMap) {
  338. const { main, allContexts } = tsconfigPathsMap;
  339. if (!requestPath) {
  340. return main;
  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. // Remember the original value before potentially appending .json
  381. const originalExtendedConfigValue = extendedConfigValue;
  382. if (
  383. typeof extendedConfigValue === "string" &&
  384. !extendedConfigValue.includes(".json")
  385. ) {
  386. extendedConfigValue += ".json";
  387. }
  388. let extendedConfigPath = join(currentDir, extendedConfigValue);
  389. const exists = await new Promise((resolve) => {
  390. fileSystem.readFile(extendedConfigPath, (err) => {
  391. resolve(!err);
  392. });
  393. });
  394. if (!exists) {
  395. // Handle scoped package extends like "@scope/name" (no sub-path):
  396. // "@scope/name" should resolve to node_modules/@scope/name/tsconfig.json,
  397. // not node_modules/@scope/name.json
  398. // See: test/fixtures/tsconfig-paths/extends-pkg-entry/
  399. if (
  400. typeof originalExtendedConfigValue === "string" &&
  401. originalExtendedConfigValue.startsWith("@") &&
  402. originalExtendedConfigValue.split("/").length === 2
  403. ) {
  404. extendedConfigPath = join(
  405. currentDir,
  406. normalize(
  407. `node_modules/${originalExtendedConfigValue}/${DEFAULT_CONFIG_FILE}`,
  408. ),
  409. );
  410. } else if (extendedConfigValue.includes("/")) {
  411. // Handle package sub-path extends like "react/tsconfig":
  412. // "react/tsconfig" resolves to node_modules/react/tsconfig.json
  413. // See: test/fixtures/tsconfig-paths/extends-npm/
  414. extendedConfigPath = join(
  415. currentDir,
  416. normalize(`node_modules/${extendedConfigValue}`),
  417. );
  418. }
  419. }
  420. const config = await this._loadTsconfig(
  421. fileSystem,
  422. extendedConfigPath,
  423. fileDependencies,
  424. visitedConfigPaths,
  425. );
  426. const compilerOptions = config.compilerOptions || { baseUrl: undefined };
  427. if (compilerOptions.baseUrl) {
  428. const extendedConfigDir = dirname(extendedConfigPath);
  429. compilerOptions.baseUrl = getAbsoluteBaseUrl(
  430. extendedConfigDir,
  431. compilerOptions.baseUrl,
  432. );
  433. }
  434. delete config.references;
  435. return /** @type {Tsconfig} */ (config);
  436. }
  437. /**
  438. * Load referenced tsconfig projects and store in referenceMatchMap
  439. * Simple implementation matching tsconfig-paths-webpack-plugin:
  440. * Just load each reference and store independently
  441. * @param {FileSystem} fileSystem the file system
  442. * @param {string} context the context
  443. * @param {TsconfigReference[]} references array of references
  444. * @param {Set<string>} fileDependencies the file dependencies
  445. * @param {{ [baseUrl: string]: TsconfigPathsData }} referenceMatchMap the map to populate
  446. * @returns {Promise<void>}
  447. */
  448. async _loadTsconfigReferences(
  449. fileSystem,
  450. context,
  451. references,
  452. fileDependencies,
  453. referenceMatchMap,
  454. ) {
  455. await Promise.all(
  456. references.map(async (ref) => {
  457. const refPath = substituteConfigDir(ref.path, context);
  458. const refConfigPath = join(join(context, refPath), DEFAULT_CONFIG_FILE);
  459. try {
  460. const refConfig = await this._loadTsconfig(
  461. fileSystem,
  462. refConfigPath,
  463. fileDependencies,
  464. );
  465. if (refConfig.compilerOptions && refConfig.compilerOptions.paths) {
  466. const refContext = dirname(refConfigPath);
  467. referenceMatchMap[refContext] = tsconfigPathsToResolveOptions(
  468. refContext,
  469. refConfig.compilerOptions.paths || {},
  470. refConfig.compilerOptions.baseUrl,
  471. );
  472. }
  473. if (
  474. this.references === "auto" &&
  475. Array.isArray(refConfig.references)
  476. ) {
  477. await this._loadTsconfigReferences(
  478. fileSystem,
  479. dirname(refConfigPath),
  480. refConfig.references,
  481. fileDependencies,
  482. referenceMatchMap,
  483. );
  484. }
  485. } catch (_err) {
  486. // continue
  487. }
  488. }),
  489. );
  490. }
  491. /**
  492. * Load tsconfig.json with extends support
  493. * @param {FileSystem} fileSystem the file system
  494. * @param {string} configFilePath absolute path to tsconfig.json
  495. * @param {Set<string>} fileDependencies the file dependencies
  496. * @param {Set<string>=} visitedConfigPaths config paths being loaded (for circular extends detection)
  497. * @returns {Promise<Tsconfig>} the merged tsconfig
  498. */
  499. async _loadTsconfig(
  500. fileSystem,
  501. configFilePath,
  502. fileDependencies,
  503. visitedConfigPaths = new Set(),
  504. ) {
  505. if (visitedConfigPaths.has(configFilePath)) {
  506. return /** @type {Tsconfig} */ ({});
  507. }
  508. visitedConfigPaths.add(configFilePath);
  509. const config = await readJson(fileSystem, configFilePath, {
  510. stripComments: true,
  511. });
  512. fileDependencies.add(configFilePath);
  513. let result = config;
  514. const extendedConfig = config.extends;
  515. if (extendedConfig) {
  516. let base;
  517. if (Array.isArray(extendedConfig)) {
  518. base = {};
  519. for (const extendedConfigElement of extendedConfig) {
  520. const extendedTsconfig = await this._loadTsconfigFromExtends(
  521. fileSystem,
  522. configFilePath,
  523. extendedConfigElement,
  524. fileDependencies,
  525. visitedConfigPaths,
  526. );
  527. base = mergeTsconfigs(base, extendedTsconfig);
  528. }
  529. } else {
  530. base = await this._loadTsconfigFromExtends(
  531. fileSystem,
  532. configFilePath,
  533. extendedConfig,
  534. fileDependencies,
  535. visitedConfigPaths,
  536. );
  537. }
  538. result = /** @type {Tsconfig} */ (mergeTsconfigs(base, config));
  539. }
  540. return result;
  541. }
  542. };