TsconfigPathsPlugin.js 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908
  1. /*
  2. MIT License http://www.opensource.org/licenses/mit-license.php
  3. Author Natsu @xiaoxiaojx
  4. */
  5. "use strict";
  6. const { aliasResolveHandler, compileAliasOptions } = require("./AliasUtils");
  7. const { modulesResolveHandler } = require("./ModulesUtils");
  8. const { readJson } = require("./util/fs");
  9. const { PathType: _PathType, isSubPath, normalize } = require("./util/path");
  10. /** @typedef {import("./Resolver")} Resolver */
  11. /** @typedef {import("./Resolver").ResolveStepHook} ResolveStepHook */
  12. /** @typedef {import("./AliasUtils").AliasOption} AliasOption */
  13. /** @typedef {import("./Resolver").ResolveRequest} ResolveRequest */
  14. /** @typedef {import("./Resolver").ResolveContext} ResolveContext */
  15. /** @typedef {import("./Resolver").FileSystem} FileSystem */
  16. /** @typedef {import("./Resolver").TsconfigPathsData} TsconfigPathsData */
  17. /** @typedef {import("./Resolver").TsconfigPathsMap} TsconfigPathsMap */
  18. /** @typedef {import("./ResolverFactory").TsconfigOptions} TsconfigOptions */
  19. // Sentinel stored in `_contextSelectionCache` for `requestPath`s whose
  20. // scan returned `null` ("no context matched"). Using a non-null marker
  21. // lets the cache-hit path be a single `Map.get()` — we distinguish
  22. // "cached null" from "not cached yet" without a second `has` lookup.
  23. const NULL_CONTEXT = Symbol("NULL_CONTEXT");
  24. /**
  25. * Per-`TsconfigPathsMap` memoization of `_selectPathsDataForContext`.
  26. *
  27. * Real-world builds resolve hundreds of requests per source file (every
  28. * import in the file), and webpack-style resolvers walk the same
  29. * `requestPath` (= source-file directory) for each one. Without this
  30. * cache every resolve re-scans the full `contextList` even though the
  31. * answer is invariant for a given `(map, requestPath)` pair.
  32. *
  33. * The outer key is the `TsconfigPathsMap` itself — rebuilt on every
  34. * tsconfig change — so a `WeakMap` lets the inner map be collected
  35. * automatically once the map goes away. The inner Map is keyed by
  36. * `requestPath` (string); a `Symbol` sentinel stands in for "no
  37. * matching context" so `Map.get` alone distinguishes the three states
  38. * (cached data / cached null / not cached).
  39. * @type {WeakMap<TsconfigPathsMap, Map<string, TsconfigPathsData | typeof NULL_CONTEXT>>}
  40. */
  41. const _contextSelectionCache = new WeakMap();
  42. /**
  43. * @typedef {object} TsconfigCompilerOptions
  44. * @property {string=} baseUrl Base URL for resolving paths
  45. * @property {{ [key: string]: string[] }=} paths TypeScript paths mapping
  46. */
  47. /**
  48. * @typedef {object} TsconfigReference
  49. * @property {string} path Path to the referenced project
  50. */
  51. /**
  52. * @typedef {object} Tsconfig
  53. * @property {TsconfigCompilerOptions=} compilerOptions Compiler options
  54. * @property {string | string[]=} extends Extended configuration paths
  55. * @property {TsconfigReference[]=} references Project references
  56. */
  57. const DEFAULT_CONFIG_FILE = "tsconfig.json";
  58. const READ_JSON_OPTIONS = { stripComments: true };
  59. // Trailing `/*` or `\*` segment of a tsconfig `paths` mapping (e.g.
  60. // `./src/*` → `./src`). Hoisted so we don't allocate a fresh regex per
  61. // path entry on every tsconfig load — and so the same regex object can be
  62. // reused for the matching `test` + `replace` pair below.
  63. const WILDCARD_TAIL_RE = /[/\\]\*$/;
  64. /**
  65. * @param {string} pattern Path pattern
  66. * @returns {number} Length of the prefix
  67. */
  68. function getPrefixLength(pattern) {
  69. const prefixLength = pattern.indexOf("*");
  70. if (prefixLength === -1) {
  71. return pattern.length;
  72. }
  73. return prefixLength;
  74. }
  75. /**
  76. * Sort path patterns.
  77. * If a module name can be matched with multiple patterns then pattern with the longest prefix will be picked.
  78. * @param {string[]} arr Array of path patterns
  79. * @returns {string[]} Array of path patterns sorted by longest prefix
  80. */
  81. function sortByLongestPrefix(arr) {
  82. return [...arr].sort((a, b) => getPrefixLength(b) - getPrefixLength(a));
  83. }
  84. /**
  85. * Merge two tsconfig objects
  86. * @param {Tsconfig | null} base base config
  87. * @param {Tsconfig | null} config config to merge
  88. * @returns {Tsconfig} merged config
  89. */
  90. function mergeTsconfigs(base, config) {
  91. base = base || {};
  92. config = config || {};
  93. return {
  94. ...base,
  95. ...config,
  96. compilerOptions: {
  97. .../** @type {TsconfigCompilerOptions} */ (base.compilerOptions),
  98. .../** @type {TsconfigCompilerOptions} */ (config.compilerOptions),
  99. },
  100. };
  101. }
  102. /**
  103. * Substitute ${configDir} template variable in path
  104. * @param {string} pathValue the path value
  105. * @param {string} configDir the config directory
  106. * @returns {string} the path with substituted template
  107. */
  108. function substituteConfigDir(pathValue, configDir) {
  109. // eslint-disable-next-line no-template-curly-in-string
  110. if (!pathValue.includes("${configDir}")) return pathValue;
  111. return pathValue.replace(/\$\{configDir\}/g, configDir);
  112. }
  113. /**
  114. * Convert tsconfig paths to resolver options
  115. * @param {string} configDir Config file directory
  116. * @param {{ [key: string]: string[] }} paths TypeScript paths mapping
  117. * @param {Resolver} resolver resolver instance
  118. * @param {string=} baseUrl Base URL for resolving paths (relative to configDir)
  119. * @returns {TsconfigPathsData} the resolver options
  120. */
  121. function tsconfigPathsToResolveOptions(configDir, paths, resolver, baseUrl) {
  122. // Calculate absolute base URL
  123. const absoluteBaseUrl = !baseUrl
  124. ? configDir
  125. : resolver.join(configDir, baseUrl);
  126. /** @type {string[]} */
  127. const sortedKeys = sortByLongestPrefix(Object.keys(paths));
  128. /** @type {AliasOption[]} */
  129. const alias = [];
  130. /** @type {string[]} */
  131. const modules = [];
  132. for (const pattern of sortedKeys) {
  133. const mappings = paths[pattern];
  134. // Substitute ${configDir} in path mappings
  135. const absolutePaths = mappings.map((mapping) => {
  136. const substituted = substituteConfigDir(mapping, configDir);
  137. return resolver.join(absoluteBaseUrl, substituted);
  138. });
  139. if (absolutePaths.length > 0) {
  140. if (pattern === "*") {
  141. // Pull `dir/*` entries directly into `modules` with their
  142. // trailing wildcard stripped, skipping anything else. The
  143. // previous `.map(...).filter(Boolean)` form allocated two
  144. // throwaway arrays plus a spread iterator per `*` mapping.
  145. for (let j = 0; j < absolutePaths.length; j++) {
  146. const dir = absolutePaths[j];
  147. if (WILDCARD_TAIL_RE.test(dir)) {
  148. modules.push(dir.replace(WILDCARD_TAIL_RE, ""));
  149. }
  150. }
  151. } else {
  152. alias.push({ name: pattern, alias: absolutePaths });
  153. }
  154. }
  155. }
  156. if (baseUrl && absoluteBaseUrl && !modules.includes(absoluteBaseUrl)) {
  157. modules.push(absoluteBaseUrl);
  158. }
  159. return {
  160. alias: compileAliasOptions(resolver, alias),
  161. modules,
  162. };
  163. }
  164. /**
  165. * Get the base context for the current project
  166. * @param {string} context the context
  167. * @param {Resolver} resolver resolver instance
  168. * @param {string=} baseUrl base URL for resolving paths
  169. * @returns {string} the base context
  170. */
  171. function getAbsoluteBaseUrl(context, resolver, baseUrl) {
  172. return !baseUrl ? context : resolver.join(context, baseUrl);
  173. }
  174. /**
  175. * @param {TsconfigPathsData} main main paths data
  176. * @param {string} mainContext main context
  177. * @param {{ [baseUrl: string]: TsconfigPathsData }} refs references map
  178. * @param {Set<string>} fileDependencies file dependencies
  179. * @returns {TsconfigPathsMap} the tsconfig paths map
  180. */
  181. function buildTsconfigPathsMap(main, mainContext, refs, fileDependencies) {
  182. const allContexts = /** @type {{ [context: string]: TsconfigPathsData }} */ ({
  183. [mainContext]: main,
  184. ...refs,
  185. });
  186. // Precompute the key list once per tsconfig load. `_selectPathsDataForContext`
  187. // runs per resolve and otherwise would call `Object.entries(allContexts)`
  188. // each time, allocating a fresh [key, value][] array.
  189. const contextList = Object.keys(allContexts);
  190. return {
  191. main,
  192. mainContext,
  193. refs,
  194. allContexts,
  195. contextList,
  196. fileDependencies,
  197. };
  198. }
  199. module.exports = class TsconfigPathsPlugin {
  200. /**
  201. * @param {true | string | TsconfigOptions} configFileOrOptions tsconfig file path or options object
  202. */
  203. constructor(configFileOrOptions) {
  204. if (
  205. typeof configFileOrOptions === "object" &&
  206. configFileOrOptions !== null
  207. ) {
  208. // Options object format
  209. const { configFile } = configFileOrOptions;
  210. /** @type {boolean} */
  211. this.isAutoConfigFile = typeof configFile !== "string";
  212. /** @type {string} */
  213. this.configFile = this.isAutoConfigFile
  214. ? DEFAULT_CONFIG_FILE
  215. : /** @type {string} */ (configFile);
  216. /** @type {string[] | "auto"} */
  217. if (Array.isArray(configFileOrOptions.references)) {
  218. /** @type {TsconfigReference[] | "auto"} */
  219. this.references = configFileOrOptions.references.map((ref) => ({
  220. path: ref,
  221. }));
  222. } else if (configFileOrOptions.references === "auto") {
  223. this.references = "auto";
  224. } else {
  225. this.references = [];
  226. }
  227. /** @type {string | undefined} */
  228. this.baseUrl = configFileOrOptions.baseUrl;
  229. } else {
  230. /** @type {boolean} */
  231. this.isAutoConfigFile = configFileOrOptions === true;
  232. /** @type {string} */
  233. this.configFile = this.isAutoConfigFile
  234. ? DEFAULT_CONFIG_FILE
  235. : /** @type {string} */ (configFileOrOptions);
  236. /** @type {TsconfigReference[] | "auto"} */
  237. this.references = [];
  238. /** @type {string | undefined} */
  239. this.baseUrl = undefined;
  240. }
  241. }
  242. /**
  243. * @param {Resolver} resolver the resolver
  244. * @returns {void}
  245. */
  246. apply(resolver) {
  247. const aliasTarget = resolver.ensureHook("internal-resolve");
  248. const moduleTarget = resolver.ensureHook("module");
  249. resolver
  250. .getHook("raw-resolve")
  251. .tapAsync("TsconfigPathsPlugin", (request, resolveContext, callback) => {
  252. this._getTsconfigPathsMap(
  253. resolver,
  254. request,
  255. resolveContext,
  256. (err, tsconfigPathsMap) => {
  257. if (err) return callback(err);
  258. if (!tsconfigPathsMap) return callback();
  259. const selectedData = this._selectPathsDataForContext(
  260. request.path,
  261. tsconfigPathsMap,
  262. );
  263. if (!selectedData) return callback();
  264. aliasResolveHandler(
  265. resolver,
  266. selectedData.alias,
  267. aliasTarget,
  268. request,
  269. resolveContext,
  270. (err, result) => {
  271. if (err) return callback(err);
  272. if (result) return callback(null, result);
  273. // https://github.com/webpack/webpack/issues/20944
  274. // Unlike resolve.alias, tsconfig paths should fall through
  275. // when a pattern matches but the mapped path does not exist
  276. // (matching TypeScript's native resolution behavior).
  277. return callback();
  278. },
  279. );
  280. },
  281. );
  282. });
  283. resolver
  284. .getHook("raw-module")
  285. .tapAsync("TsconfigPathsPlugin", (request, resolveContext, callback) => {
  286. this._getTsconfigPathsMap(
  287. resolver,
  288. request,
  289. resolveContext,
  290. (err, tsconfigPathsMap) => {
  291. if (err) return callback(err);
  292. if (!tsconfigPathsMap) return callback();
  293. const selectedData = this._selectPathsDataForContext(
  294. request.path,
  295. tsconfigPathsMap,
  296. );
  297. if (!selectedData) return callback();
  298. modulesResolveHandler(
  299. resolver,
  300. selectedData.modules,
  301. moduleTarget,
  302. request,
  303. resolveContext,
  304. callback,
  305. );
  306. },
  307. );
  308. });
  309. }
  310. /**
  311. * Get TsconfigPathsMap for the request (with caching)
  312. * @param {Resolver} resolver the resolver
  313. * @param {ResolveRequest} request the request
  314. * @param {ResolveContext} resolveContext the resolve context
  315. * @param {(err: Error | null, result?: TsconfigPathsMap | null) => void} callback the callback
  316. * @returns {void}
  317. */
  318. _getTsconfigPathsMap(resolver, request, resolveContext, callback) {
  319. if (typeof request.tsconfigPathsMap !== "undefined") {
  320. const cached = request.tsconfigPathsMap;
  321. if (!cached) return callback(null, null);
  322. if (resolveContext.fileDependencies) {
  323. for (const fileDependency of cached.fileDependencies) {
  324. resolveContext.fileDependencies.add(fileDependency);
  325. }
  326. }
  327. return callback(null, cached);
  328. }
  329. if (this.isAutoConfigFile) {
  330. this._findTsconfigUpward(
  331. resolver,
  332. request.path || process.cwd(),
  333. (err, result) => {
  334. if (err) {
  335. request.tsconfigPathsMap = null;
  336. return callback(err);
  337. }
  338. if (!result) {
  339. request.tsconfigPathsMap = null;
  340. return callback(null, null);
  341. }
  342. const map = /** @type {TsconfigPathsMap} */ (result);
  343. request.tsconfigPathsMap = map;
  344. if (resolveContext.fileDependencies) {
  345. for (const fileDependency of map.fileDependencies) {
  346. resolveContext.fileDependencies.add(fileDependency);
  347. }
  348. }
  349. callback(null, map);
  350. },
  351. );
  352. return;
  353. }
  354. const absTsconfigPath = resolver.join(
  355. request.path || process.cwd(),
  356. this.configFile,
  357. );
  358. this._loadTsconfigPathsMap(resolver, absTsconfigPath, (err, result) => {
  359. if (err) {
  360. request.tsconfigPathsMap = null;
  361. return callback(err);
  362. }
  363. const map = /** @type {TsconfigPathsMap} */ (result);
  364. request.tsconfigPathsMap = map;
  365. if (resolveContext.fileDependencies) {
  366. for (const fileDependency of map.fileDependencies) {
  367. resolveContext.fileDependencies.add(fileDependency);
  368. }
  369. }
  370. callback(null, map);
  371. });
  372. }
  373. /**
  374. * Walk up from startDir to the filesystem root looking for tsconfig.json.
  375. * Like TypeScript's own `findConfigFile` / `forEachAncestorDirectory`.
  376. * @param {Resolver} resolver the resolver
  377. * @param {string} startDir the directory to start searching from
  378. * @param {(err: Error | null, result?: TsconfigPathsMap | null) => void} callback the callback
  379. * @returns {void}
  380. */
  381. _findTsconfigUpward(resolver, startDir, callback) {
  382. const { fileSystem } = resolver;
  383. const configFileName = this.configFile;
  384. /**
  385. * @param {string} dir current directory
  386. * @returns {void}
  387. */
  388. const check = (dir) => {
  389. const candidate = resolver.join(dir, configFileName);
  390. fileSystem.stat(candidate, (statErr) => {
  391. if (!statErr) {
  392. // Found — load it
  393. this._loadTsconfigPathsMap(resolver, candidate, (loadErr, result) => {
  394. if (loadErr) {
  395. // Auto mode: soft-fail silently.
  396. return callback(null, null);
  397. }
  398. callback(null, result);
  399. });
  400. return;
  401. }
  402. // Not found — move to parent
  403. const parentDir = resolver.dirname(dir);
  404. if (parentDir === dir) {
  405. // Reached filesystem root, no tsconfig.json found
  406. return callback(null, null);
  407. }
  408. check(parentDir);
  409. });
  410. };
  411. check(startDir);
  412. }
  413. /**
  414. * Load tsconfig.json and build complete TsconfigPathsMap
  415. * Includes main project paths and all referenced projects
  416. * @param {Resolver} resolver the resolver
  417. * @param {string} absTsconfigPath absolute path to tsconfig.json
  418. * @param {(err: Error | null, result?: TsconfigPathsMap) => void} callback the callback
  419. * @returns {void}
  420. */
  421. _loadTsconfigPathsMap(resolver, absTsconfigPath, callback) {
  422. /** @type {Set<string>} */
  423. const fileDependencies = new Set();
  424. this._loadTsconfig(
  425. resolver,
  426. absTsconfigPath,
  427. fileDependencies,
  428. undefined,
  429. (err, config) => {
  430. if (err) return callback(err);
  431. const cfg = /** @type {Tsconfig} */ (config);
  432. const compilerOptions = cfg.compilerOptions || {};
  433. const mainContext = resolver.dirname(absTsconfigPath);
  434. const baseUrl =
  435. this.baseUrl !== undefined ? this.baseUrl : compilerOptions.baseUrl;
  436. const main = tsconfigPathsToResolveOptions(
  437. mainContext,
  438. compilerOptions.paths || {},
  439. resolver,
  440. baseUrl,
  441. );
  442. /** @type {{ [baseUrl: string]: TsconfigPathsData }} */
  443. const refs = {};
  444. let referencesToUse = null;
  445. if (this.references === "auto") {
  446. referencesToUse = cfg.references;
  447. } else if (Array.isArray(this.references)) {
  448. referencesToUse = this.references;
  449. }
  450. if (!Array.isArray(referencesToUse)) {
  451. return callback(
  452. null,
  453. buildTsconfigPathsMap(main, mainContext, refs, fileDependencies),
  454. );
  455. }
  456. this._loadTsconfigReferences(
  457. resolver,
  458. mainContext,
  459. referencesToUse,
  460. fileDependencies,
  461. refs,
  462. (refErr) => {
  463. if (refErr) return callback(refErr);
  464. callback(
  465. null,
  466. buildTsconfigPathsMap(main, mainContext, refs, fileDependencies),
  467. );
  468. },
  469. );
  470. },
  471. );
  472. }
  473. /**
  474. * Select the correct TsconfigPathsData based on request.path (context-aware)
  475. * Matches the behavior of tsconfig-paths-webpack-plugin
  476. * @param {string | false} requestPath the request path
  477. * @param {TsconfigPathsMap} tsconfigPathsMap the tsconfig paths map
  478. * @returns {TsconfigPathsData | null} the selected paths data
  479. */
  480. _selectPathsDataForContext(requestPath, tsconfigPathsMap) {
  481. const { main, allContexts, contextList } = tsconfigPathsMap;
  482. if (!requestPath) {
  483. return main;
  484. }
  485. // Single-context tsconfigs (no project references) hit the loop
  486. // below at most once; in that case the cache lookup costs more
  487. // than the loop itself. Only memoize when there are 2+ contexts
  488. // — that's the monorepo / project-references shape where the
  489. // scan actually walks multiple entries per resolve and the
  490. // `(map, requestPath)` answer can be reused.
  491. /** @type {Map<string, TsconfigPathsData | typeof NULL_CONTEXT> | undefined} */
  492. let perMap;
  493. if (contextList.length >= 2) {
  494. perMap = _contextSelectionCache.get(tsconfigPathsMap);
  495. if (perMap !== undefined) {
  496. const cached = perMap.get(requestPath);
  497. if (cached !== undefined) {
  498. return cached === NULL_CONTEXT
  499. ? null
  500. : /** @type {TsconfigPathsData} */ (cached);
  501. }
  502. } else {
  503. perMap = new Map();
  504. _contextSelectionCache.set(tsconfigPathsMap, perMap);
  505. }
  506. }
  507. let longestMatchContext = null;
  508. let longestMatchLength = 0;
  509. // Iterate the pre-computed key list (the previous
  510. // `Object.entries(allContexts)` form allocated a fresh
  511. // `[key, value][]` per resolve). Defer the `allContexts[context]`
  512. // lookup to after we know the context actually matches — non-matches
  513. // are the common case and don't need the property access.
  514. for (let i = 0; i < contextList.length; i++) {
  515. const context = contextList[i];
  516. if (context === requestPath) {
  517. const exact = allContexts[context];
  518. if (perMap !== undefined) perMap.set(requestPath, exact);
  519. return exact;
  520. }
  521. // Cheap integer-compare gate first: a context can only beat the
  522. // current longest match if its own length is strictly greater.
  523. // Skipping `isSubPath` (a `startsWith` + char-code probe) when the
  524. // length already disqualifies the candidate avoids the per-resolve
  525. // scan over every shorter context.
  526. if (
  527. context.length > longestMatchLength &&
  528. isSubPath(context, requestPath)
  529. ) {
  530. longestMatchContext = context;
  531. longestMatchLength = context.length;
  532. }
  533. }
  534. const result =
  535. longestMatchContext === null ? null : allContexts[longestMatchContext];
  536. if (perMap !== undefined) {
  537. perMap.set(requestPath, result === null ? NULL_CONTEXT : result);
  538. }
  539. return result;
  540. }
  541. /**
  542. * Load tsconfig from extends path
  543. * @param {Resolver} resolver the resolver
  544. * @param {string} configFilePath current config file path
  545. * @param {string} extendedConfigValue extends value
  546. * @param {Set<string>} fileDependencies the file dependencies
  547. * @param {Set<string>} visitedConfigPaths config paths being loaded (for circular extends detection)
  548. * @param {(err: Error | null, result?: Tsconfig) => void} callback callback
  549. * @returns {void}
  550. */
  551. _loadTsconfigFromExtends(
  552. resolver,
  553. configFilePath,
  554. extendedConfigValue,
  555. fileDependencies,
  556. visitedConfigPaths,
  557. callback,
  558. ) {
  559. const { fileSystem } = resolver;
  560. const currentDir = resolver.dirname(configFilePath);
  561. // Substitute ${configDir} in extends path
  562. extendedConfigValue = substituteConfigDir(extendedConfigValue, currentDir);
  563. // Remember the original value before potentially appending .json
  564. const originalExtendedConfigValue = extendedConfigValue;
  565. if (
  566. typeof extendedConfigValue === "string" &&
  567. !extendedConfigValue.includes(".json")
  568. ) {
  569. extendedConfigValue += ".json";
  570. }
  571. const initialExtendedConfigPath = resolver.join(
  572. currentDir,
  573. extendedConfigValue,
  574. );
  575. /**
  576. * @param {string} extendedConfigPath resolved config path to load
  577. */
  578. const loadExtended = (extendedConfigPath) => {
  579. this._loadTsconfig(
  580. resolver,
  581. extendedConfigPath,
  582. fileDependencies,
  583. visitedConfigPaths,
  584. (err, config) => {
  585. if (err) return callback(err);
  586. const cfg = /** @type {Tsconfig} */ (config);
  587. const compilerOptions = cfg.compilerOptions || {
  588. baseUrl: undefined,
  589. };
  590. if (compilerOptions.baseUrl) {
  591. const extendedConfigDir = resolver.dirname(extendedConfigPath);
  592. compilerOptions.baseUrl = getAbsoluteBaseUrl(
  593. extendedConfigDir,
  594. resolver,
  595. compilerOptions.baseUrl,
  596. );
  597. }
  598. delete cfg.references;
  599. callback(null, cfg);
  600. },
  601. );
  602. };
  603. fileSystem.stat(initialExtendedConfigPath, (existsErr) => {
  604. if (!existsErr) return loadExtended(initialExtendedConfigPath);
  605. // The relative form does not exist — treat the value as a package
  606. // specifier and compute its `node_modules/<...>` sub-path.
  607. let nodeModulesSubPath = null;
  608. if (
  609. typeof originalExtendedConfigValue === "string" &&
  610. originalExtendedConfigValue.startsWith("@") &&
  611. originalExtendedConfigValue.split("/").length === 2
  612. ) {
  613. // Scoped package, no sub-path ("@scope/name") →
  614. // node_modules/@scope/name/tsconfig.json (not @scope/name.json).
  615. // See: test/fixtures/tsconfig-paths/extends-pkg-entry/
  616. nodeModulesSubPath = `${originalExtendedConfigValue}/${DEFAULT_CONFIG_FILE}`;
  617. } else if (
  618. extendedConfigValue.includes("/") &&
  619. !originalExtendedConfigValue.startsWith(".") &&
  620. !originalExtendedConfigValue.startsWith("/")
  621. ) {
  622. // Package sub-path ("react/tsconfig", "@scope/name/tsconfig") →
  623. // node_modules/react/tsconfig.json.
  624. // See: test/fixtures/tsconfig-paths/extends-npm/
  625. nodeModulesSubPath = extendedConfigValue;
  626. } else if (
  627. !originalExtendedConfigValue.startsWith(".") &&
  628. !originalExtendedConfigValue.startsWith("/")
  629. ) {
  630. // Unscoped package, no sub-path ("my-base-config") →
  631. // node_modules/my-base-config/tsconfig.json.
  632. nodeModulesSubPath = `${originalExtendedConfigValue}/${DEFAULT_CONFIG_FILE}`;
  633. }
  634. // Not a package specifier (relative/absolute) — load the missing
  635. // path anyway so the read surfaces its own ENOENT.
  636. if (nodeModulesSubPath === null) {
  637. return loadExtended(initialExtendedConfigPath);
  638. }
  639. // Walk ancestor node_modules like Node.js/TypeScript so a package
  640. // hoisted to a parent workspace directory is found. #21457
  641. const subPath = normalize(`node_modules/${nodeModulesSubPath}`);
  642. this._findExtendsInNodeModules(resolver, currentDir, subPath, (found) => {
  643. loadExtended(found || resolver.join(currentDir, subPath));
  644. });
  645. });
  646. }
  647. /**
  648. * Walk up from startDir looking for `<dir>/<subPath>` (a
  649. * `node_modules/...` sub-path), matching Node.js module resolution so a
  650. * package hoisted to a parent workspace's node_modules is found.
  651. * @param {Resolver} resolver the resolver
  652. * @param {string} startDir directory to start searching from
  653. * @param {string} subPath node_modules-relative sub-path to look for
  654. * @param {(found: string | null) => void} callback receives the found path or null
  655. * @returns {void}
  656. */
  657. _findExtendsInNodeModules(resolver, startDir, subPath, callback) {
  658. const { fileSystem } = resolver;
  659. /**
  660. * @param {string} dir current directory
  661. */
  662. const check = (dir) => {
  663. const candidate = resolver.join(dir, subPath);
  664. fileSystem.stat(candidate, (statErr) => {
  665. if (!statErr) return callback(candidate);
  666. const parentDir = resolver.dirname(dir);
  667. if (parentDir === dir) return callback(null);
  668. check(parentDir);
  669. });
  670. };
  671. check(startDir);
  672. }
  673. /**
  674. * Load referenced tsconfig projects and store in referenceMatchMap
  675. * Simple implementation matching tsconfig-paths-webpack-plugin:
  676. * Just load each reference and store independently
  677. * @param {Resolver} resolver the resolver
  678. * @param {string} context the context
  679. * @param {TsconfigReference[]} references array of references
  680. * @param {Set<string>} fileDependencies the file dependencies
  681. * @param {{ [baseUrl: string]: TsconfigPathsData }} referenceMatchMap the map to populate
  682. * @param {(err: Error | null) => void} callback callback
  683. * @param {Set<string>=} visitedRefPaths visited reference config paths (for circular reference detection)
  684. * @returns {void}
  685. */
  686. _loadTsconfigReferences(
  687. resolver,
  688. context,
  689. references,
  690. fileDependencies,
  691. referenceMatchMap,
  692. callback,
  693. visitedRefPaths,
  694. ) {
  695. if (references.length === 0) return callback(null);
  696. const visited = visitedRefPaths || new Set();
  697. let pending = references.length;
  698. const finishOne = () => {
  699. if (--pending === 0) callback(null);
  700. };
  701. for (const ref of references) {
  702. const refPath = substituteConfigDir(ref.path, context);
  703. const refConfigPath = resolver.join(
  704. resolver.join(context, refPath),
  705. DEFAULT_CONFIG_FILE,
  706. );
  707. if (visited.has(refConfigPath)) {
  708. finishOne();
  709. continue;
  710. }
  711. visited.add(refConfigPath);
  712. this._loadTsconfig(
  713. resolver,
  714. refConfigPath,
  715. fileDependencies,
  716. undefined,
  717. (err, refConfig) => {
  718. // Failures are swallowed to match tsconfig-paths-webpack-plugin:
  719. // a broken reference must not abort the main project's resolution.
  720. if (err) return finishOne();
  721. const cfg = /** @type {Tsconfig} */ (refConfig);
  722. if (cfg.compilerOptions && cfg.compilerOptions.paths) {
  723. const refContext = resolver.dirname(refConfigPath);
  724. referenceMatchMap[refContext] = tsconfigPathsToResolveOptions(
  725. refContext,
  726. cfg.compilerOptions.paths || {},
  727. resolver,
  728. cfg.compilerOptions.baseUrl,
  729. );
  730. }
  731. if (this.references === "auto" && Array.isArray(cfg.references)) {
  732. this._loadTsconfigReferences(
  733. resolver,
  734. resolver.dirname(refConfigPath),
  735. cfg.references,
  736. fileDependencies,
  737. referenceMatchMap,
  738. finishOne,
  739. visited,
  740. );
  741. } else {
  742. finishOne();
  743. }
  744. },
  745. );
  746. }
  747. }
  748. /**
  749. * Load tsconfig.json with extends support
  750. * @param {Resolver} resolver the resolver
  751. * @param {string} configFilePath absolute path to tsconfig.json
  752. * @param {Set<string>} fileDependencies the file dependencies
  753. * @param {Set<string> | undefined} visitedConfigPaths config paths being loaded (for circular extends detection)
  754. * @param {(err: Error | null, result?: Tsconfig) => void} callback callback
  755. * @returns {void}
  756. */
  757. _loadTsconfig(
  758. resolver,
  759. configFilePath,
  760. fileDependencies,
  761. visitedConfigPaths,
  762. callback,
  763. ) {
  764. const visited = visitedConfigPaths || new Set();
  765. if (visited.has(configFilePath)) {
  766. return callback(null, /** @type {Tsconfig} */ ({}));
  767. }
  768. visited.add(configFilePath);
  769. readJson(
  770. resolver.fileSystem,
  771. configFilePath,
  772. READ_JSON_OPTIONS,
  773. (err, parsed) => {
  774. if (err) return callback(/** @type {Error} */ (err));
  775. const config = /** @type {Tsconfig} */ (parsed);
  776. fileDependencies.add(configFilePath);
  777. const extendedConfig = config.extends;
  778. if (!extendedConfig) return callback(null, config);
  779. if (!Array.isArray(extendedConfig)) {
  780. this._loadTsconfigFromExtends(
  781. resolver,
  782. configFilePath,
  783. extendedConfig,
  784. fileDependencies,
  785. visited,
  786. (extErr, extendedTsconfig) => {
  787. if (extErr) return callback(extErr);
  788. callback(
  789. null,
  790. mergeTsconfigs(
  791. /** @type {Tsconfig} */ (extendedTsconfig),
  792. config,
  793. ),
  794. );
  795. },
  796. );
  797. return;
  798. }
  799. /** @type {Tsconfig} */
  800. let base = {};
  801. let i = 0;
  802. const next = () => {
  803. if (i >= extendedConfig.length) {
  804. return callback(null, mergeTsconfigs(base, config));
  805. }
  806. this._loadTsconfigFromExtends(
  807. resolver,
  808. configFilePath,
  809. extendedConfig[i++],
  810. fileDependencies,
  811. visited,
  812. (extErr, extendedTsconfig) => {
  813. if (extErr) return callback(extErr);
  814. base = mergeTsconfigs(
  815. base,
  816. /** @type {Tsconfig} */ (extendedTsconfig),
  817. );
  818. next();
  819. },
  820. );
  821. };
  822. next();
  823. },
  824. );
  825. }
  826. };