We have on occasion the situation where we'll have:
import { foo, bar } from '../../services/blaService';
where we have both the file blaService.ts
and the folder blaService/index.ts
.
Webpack loads the file first and discards the code in the folder which is expected behaviour.
Could we have a way to guard against this by for instance throwing an error when such a code shadowing scenario occurs?
Here would be a way to solve it:
const path = require('path');
const fs = require('fs');
const DetectShadowingPlugin = {
apply (resolver) {
const beforeResolved = resolver.getHook('before-resolved');
// `cb`- refers to the next `tap` function in the chain
beforeResolved.tapAsync('DetectShadowingPlugin', (req, ctx, cb) => {
// To inspect the hook's chain until this moment, see `ctx.stack`(from top to bottom)
// console.log(ctx);
// The `path` will give us the full path for the file we're looking for
const { path: filePath } = req;
const ext = path.extname(filePath);
if (ext !== '.js') {
// Continuing the process
return cb();
}
const fileName = path.basename(filePath, path.extname(filePath)); // https://stackoverflow.com/a/19811573/9632621
const possibleDirectoryPath = path.resolve(filePath, '..', fileName);
fs.access(possibleDirectoryPath, err => {
if (!err) {
const message = `Apart from the file ${filePath}, there is also a directory ${possibleDirectoryPath}`;
cb(new Error(message));
return;
}
cb();
});
});
},
};
/**
* @type {import("webpack/types").Configuration}
*/
const config = {
/* ... */
resolve: {
plugins: [DetectShadowingPlugin]
},
};
module.exports = config;
Result:
The file structure is as follows:
├── deps
│ ├── foo
│ │ └── index.js
│ └── foo.js
├── dist
│ └── main.js
├── index.js
└── webpack.config.js
and foo
is imported like this:
import defaultFooFn from './deps/foo';
If you want to try out the above example, you can check out this Github repo. I will add the set-up details in the repo's readme later(surely.. later :D), but until then, here are the steps:
package.json
's scripts for more information about itwebpack uses a resolver
for finding the location of the files. I see this discovery process as a collection of branch ramifications. Sort of like git branches. It has a starting point and based on some conditions, it chooses the paths to take until it reaches and endpoint.
If you copied the repo I linked in the previous section, you should see the webpack repo, in the webpack
folder. If you want to better visualize these ramifications of choices, you can open the webpack/node_modules/enhanced-resolve/lib/ResolverFactory.js
file. You don't have to understand what's going on, but just notice the connections between the steps:
as you can see, parsed-resolve
appears as an argument both on the first position and on the last position. You can also see that it's using all kinds of plugins, but they have a thing in common: generally, the first string is the source and the last string is the target. I mentioned earlier that this process can be seen as a ramification of branches. Well, these branches are composed of nodes(intuitively speaking), where a node is technically called a hook.
The starting point is the resolve
hook(from the for loop). The next node after it is parsed-resolve
(it is the resolve
hook's target). The parsed-resolve
hook's target is described-resolve
hook. And so forth.
Now, there is an important thing to mention. As you might have noticed, the described-resolve
hook is used multiple times as a source. Every time this happens, a new step(technically called tap
) is added. When moving from one node to another, these steps are used. From one step you can go another route if that plugin(a step is added by a plugin) decides so(this can be the result of certain conditions being fulfilled in a plugin).
So, if you have something like this:
plugins.push(new Plugin1("described-resolve", "another-target-1"));
plugins.push(new Plugin2("described-resolve", "another-target-1"));
plugins.push(new Plugin3("described-resolve", "another-target-2"));
From described-resolve
you can go to another-target-1
from 2 steps(so there are 2 ways to arrive to it). If one condition is not met in a plugin, it goes to the next condition until the plugin's condition is met. If another-target-1
has not been chosen at all, then maybe Plugin3
's condition will lead the way to another-target-2
.
So, this this the logic behind this process, as far as my perspective is concerned. Somewhere in this process, there is a hook(or a node, if we were to stick to the initial analogy) which is invoked after the file has been successfully found. This is the resolved
hook, which is also represents the last part of the process.
If we reached the point, we know for sure that a file exists. What we could do now is to check whether a folder with the same name exists. And this is what this custom plugin is doing:
const DetectShadowingPlugin = {
apply (resolver) {
const beforeResolved = resolver.getHook('before-resolved');
beforeResolved.tapAsync('DetectShadowingPlugin', (req, ctx, cb) => {
const { path: filePath } = req;
const ext = path.extname(filePath);
if (ext !== '.js') {
return cb();
}
const possibleDirectoryPath = path.resolve(filePath, '..', fileName);
fs.access(possibleDirectoryPath, err => {
if (!err) {
const message = `Apart from the file ${filePath}, there is also a directory ${possibleDirectoryPath}`;
cb(new Error(message));
return;
}
cb();
});
});
},
};
There is an interesting implementation detail here, which is before-resolved
. Remember that each hook, in order to determine its new target, it has to go through some conditions which are defined by plugins which use the same source. We're doing a similar thing here, with the exception that we're telling webpack to run our custom condition first. We could say that it adds some priority to it. If we wanted to run this among the last conditions, we'd replace before
with after
.
requestName.js
path first instead of requestName/index.js
This is due to the order in which the built-in plugins are added. If you scroll down in ResolverFactory
a bit, you should arrive at these lines:
// The `requestName.js` will be chosen first!
plugins.push(
new ConditionalPlugin(
"described-relative",
{ directory: false },
null,
true,
"raw-file"
)
);
// If a successful path was found, there is no way of turning back.
// So if the above way is alright, this plugin's condition won't be invoked.
plugins.push(
new ConditionalPlugin(
"described-relative",
{ fullySpecified: false },
"as directory",
true,
"directory"
)
);
You can test it out by commenting out the raw-file
plugin from above:
then, according to the repo, you should see something like that, indicating that had been chosen:
You can also place breakpoints wherever in that working tree and then press F5
to inspect the execution of the program. Everything is in the launch.json
file.