refactor(@ngtools/webpack): simplify resolution flow by using generators

With this change we refactor the paths-plugin resolution flow by using generators which makes the code more readable and easier to follow.
This commit is contained in:
Alan Agius 2022-05-12 12:21:55 +00:00 committed by Charles
parent f4fed58a05
commit a867aa4536

View File

@ -8,17 +8,20 @@
import * as path from 'path'; import * as path from 'path';
import { CompilerOptions } from 'typescript'; import { CompilerOptions } from 'typescript';
import type { Resolver } from 'webpack';
import type { Configuration } from 'webpack';
// eslint-disable-next-line @typescript-eslint/no-empty-interface // eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface TypeScriptPathsPluginOptions extends Pick<CompilerOptions, 'paths' | 'baseUrl'> {} export interface TypeScriptPathsPluginOptions extends Pick<CompilerOptions, 'paths' | 'baseUrl'> {}
// Extract Resolver type from Webpack types since it is not directly exported // Extract ResolverRequest type from Webpack types since it is not directly exported
type Resolver = Exclude<Exclude<Configuration['resolve'], undefined>['resolver'], undefined>; type ResolverRequest = NonNullable<Parameters<Parameters<Resolver['resolve']>[4]>[2]>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any interface PathPluginResolverRequest extends ResolverRequest {
type DoResolveValue = any; context?: {
issuer?: string;
};
typescriptPathMapped?: boolean;
}
interface PathPattern { interface PathPattern {
starIndex: number; starIndex: number;
@ -106,178 +109,171 @@ export class TypeScriptPathsPlugin {
// To support synchronous resolvers this hook cannot be promise based. // To support synchronous resolvers this hook cannot be promise based.
// Webpack supports synchronous resolution with `tap` and `tapAsync` hooks. // Webpack supports synchronous resolution with `tap` and `tapAsync` hooks.
resolver.getHook('described-resolve').tapAsync( resolver
'TypeScriptPathsPlugin', .getHook('described-resolve')
// eslint-disable-next-line @typescript-eslint/no-explicit-any .tapAsync(
(request: any, resolveContext, callback) => { 'TypeScriptPathsPlugin',
// Preprocessing of the options will ensure that `patterns` is either undefined or has elements to check (request: PathPluginResolverRequest, resolveContext, callback) => {
if (!this.patterns) { // Preprocessing of the options will ensure that `patterns` is either undefined or has elements to check
callback(); if (!this.patterns) {
return;
}
if (!request || request.typescriptPathMapped) {
callback();
return;
}
const originalRequest = request.request || request.path;
if (!originalRequest) {
callback();
return;
}
// Only work on Javascript/TypeScript issuers.
if (!request.context.issuer || !request.context.issuer.match(/\.[cm]?[jt]sx?$/)) {
callback();
return;
}
switch (originalRequest[0]) {
case '.':
case '/':
// Relative or absolute requests are not mapped
callback(); callback();
return; return;
case '!': }
// Ignore all webpack special requests
if (originalRequest.length > 1 && originalRequest[1] === '!') { if (!request || request.typescriptPathMapped) {
callback();
return;
}
const originalRequest = request.request || request.path;
if (!originalRequest) {
callback();
return;
}
// Only work on Javascript/TypeScript issuers.
if (!request?.context?.issuer?.match(/\.[cm]?[jt]sx?$/)) {
callback();
return;
}
switch (originalRequest[0]) {
case '.':
case '/':
// Relative or absolute requests are not mapped
callback();
return;
case '!':
// Ignore all webpack special requests
if (originalRequest.length > 1 && originalRequest[1] === '!') {
callback();
return;
}
break;
}
// A generator is used to limit the amount of replacements requests that need to be created.
// For example, if the first one resolves, any others are not needed and do not need
// to be created.
const requests = this.createReplacementRequests(request, originalRequest);
const tryResolve = () => {
const next = requests.next();
if (next.done) {
callback(); callback();
return; return;
} }
break;
}
// A generator is used to limit the amount of replacements that need to be created.
// For example, if the first one resolves, any others are not needed and do not need
// to be created.
const replacements = findReplacements(originalRequest, this.patterns);
const basePath = this.baseUrl ?? '';
const attemptResolveRequest = (request: DoResolveValue): Promise<DoResolveValue | null> => {
return new Promise((resolve, reject) => {
resolver.doResolve( resolver.doResolve(
target, target,
request, next.value,
'', '',
resolveContext, resolveContext,
(error: Error | null, result: DoResolveValue) => { (error: Error | null | undefined, result: ResolverRequest | null | undefined) => {
if (error) { if (error) {
reject(error); callback(error);
} else if (result) { } else if (result) {
resolve(result); callback(undefined, result);
} else { } else {
resolve(null); tryResolve();
} }
}, },
); );
});
};
const tryNextReplacement = () => {
const next = replacements.next();
if (next.done) {
callback();
return;
}
const targetPath = path.resolve(basePath, next.value);
// If there is no extension. i.e. the target does not refer to an explicit
// file, then this is a candidate for module/package resolution.
const canBeModule = path.extname(targetPath) === '';
// Resolution in the target location, preserving the original request.
// This will work with the `resolve-in-package` resolution hook, supporting
// package exports for e.g. locally-built APF libraries.
const potentialRequestAsPackage = {
...request,
path: targetPath,
typescriptPathMapped: true,
}; };
// Resolution in the original callee location, but with the updated request tryResolve();
// to point to the mapped target location. },
const potentialRequestAsFile = { );
...request,
request: targetPath,
typescriptPathMapped: true,
};
let resultPromise = attemptResolveRequest(potentialRequestAsFile);
// If the request can be a module, we configure the resolution to try package/module
// resolution if the file resolution did not have a result.
if (canBeModule) {
resultPromise = resultPromise.then(
(result) => result ?? attemptResolveRequest(potentialRequestAsPackage),
);
}
// If we have a result, complete. If not, and no error, try the next replacement.
resultPromise
.then((res) => (res === null ? tryNextReplacement() : callback(undefined, res)))
.catch((error) => callback(error));
};
tryNextReplacement();
},
);
} }
}
function* findReplacements( *findReplacements(originalRequest: string): IterableIterator<string> {
originalRequest: string, if (!this.patterns) {
patterns: PathPattern[], return;
): IterableIterator<string> {
// check if any path mapping rules are relevant
for (const { starIndex, prefix, suffix, potentials } of patterns) {
let partial;
if (starIndex === -1) {
// No star means an exact match is required
if (prefix === originalRequest) {
partial = '';
}
} else if (starIndex === 0 && !suffix) {
// Everything matches a single wildcard pattern ("*")
partial = originalRequest;
} else if (!suffix) {
// No suffix means the star is at the end of the pattern
if (originalRequest.startsWith(prefix)) {
partial = originalRequest.slice(prefix.length);
}
} else {
// Star was in the middle of the pattern
if (originalRequest.startsWith(prefix) && originalRequest.endsWith(suffix)) {
partial = originalRequest.substring(prefix.length, originalRequest.length - suffix.length);
}
} }
// If request was not matched, move on to the next pattern // check if any path mapping rules are relevant
if (partial === undefined) { for (const { starIndex, prefix, suffix, potentials } of this.patterns) {
continue; let partial;
}
// Create the full replacement values based on the original request and the potentials if (starIndex === -1) {
// for the successfully matched pattern. // No star means an exact match is required
for (const { hasStar, prefix, suffix } of potentials) { if (prefix === originalRequest) {
let replacement = prefix; partial = '';
}
if (hasStar) { } else if (starIndex === 0 && !suffix) {
replacement += partial; // Everything matches a single wildcard pattern ("*")
if (suffix) { partial = originalRequest;
replacement += suffix; } else if (!suffix) {
// No suffix means the star is at the end of the pattern
if (originalRequest.startsWith(prefix)) {
partial = originalRequest.slice(prefix.length);
}
} else {
// Star was in the middle of the pattern
if (originalRequest.startsWith(prefix) && originalRequest.endsWith(suffix)) {
partial = originalRequest.substring(
prefix.length,
originalRequest.length - suffix.length,
);
} }
} }
yield replacement; // If request was not matched, move on to the next pattern
if (partial === undefined) {
continue;
}
// Create the full replacement values based on the original request and the potentials
// for the successfully matched pattern.
for (const { hasStar, prefix, suffix } of potentials) {
let replacement = prefix;
if (hasStar) {
replacement += partial;
if (suffix) {
replacement += suffix;
}
}
yield replacement;
}
}
}
*createReplacementRequests(
request: PathPluginResolverRequest,
originalRequest: string,
): IterableIterator<PathPluginResolverRequest> {
for (const replacement of this.findReplacements(originalRequest)) {
const targetPath = path.resolve(this.baseUrl ?? '', replacement);
// Resolution in the original callee location, but with the updated request
// to point to the mapped target location.
yield {
...request,
request: targetPath,
typescriptPathMapped: true,
};
// If there is no extension. i.e. the target does not refer to an explicit
// file, then this is a candidate for module/package resolution.
const canBeModule = path.extname(targetPath) === '';
if (canBeModule) {
// Resolution in the target location, preserving the original request.
// This will work with the `resolve-in-package` resolution hook, supporting
// package exports for e.g. locally-built APF libraries.
yield {
...request,
path: targetPath,
typescriptPathMapped: true,
};
}
} }
} }
} }