Hooking into import with Webpack & Rollup

Learning how to take advantage of the internal tools you may use everyday can prove invaluable, especially when needing to create custom logic for yourself, your team, or even to just contribute and drive the software forward.

Prerequisites

Before we get started, what is a build-system? It sounds like a magical and yet ambiguous word. A build system is a tool that simply executes your tasks with a set of instructions.

Common examples of this are Webpack/Gulp/Rollup and even CircleCI if you try to bend it’s meaning. I am sure if you are reading this you have used at least one of these. These all use some sort of method to resolve packages or modules, and often we need to implement custom logic to account for certain file types or other requirements.

Building a plugin

To achieve hooking into import or require we will need to build a plugin for each build system. This is how we take advantage of the internal tools/APIs that our build systems provide. The initial scaffold to be able to access these look like this:

webpack:

// webpack.config.js
class YourPlugin {
apply(compiler) {
// hook into your taps here
// e.g. compiler.hooks.done
}
}

rollup:

// rollup.config.js
function yourPlugin() {
return {
name: 'your-plugin'
async oneOfTheHooksHere() {} //e.g. resolveId/load
}
}

Internal APIs

As this is just the scaffolding for a plugin and it doesn't do much, we need to hook into one of the plugin's APIs. To specifically access requires and imports you will need to:

in Rollup:

async resolveId(id, importer) {
}

resolveId is called for every module being imported, this is run recursively through all files until no further imports are found. The function is called with two parameters, which are:

  • id (String): the import used to find the module in your source. For example './path/to/importee.js'
  • import (String): the file in which you are importing the id from. For example: '/path/to/file/importer.js'

This functions purpose is to resolve any files to the correct path. It expects you to resolve this import by returning one of a few options: (any of these wrapped in a promise: Promise<string|object|null|false>)

  • string: the path to the file you would like to resolve. This will probably be our most useful to begin with as we can easily return the new path to the file we want to resolve.
  • null: This will tell rollup to defer to other resolve functions and then, once all are run, to default resolution.
  • false: This advises that the id should be handled as an external module, such as importing something from node_modules.
  • Object: a stricter explicit config for a resolution to a file. View here. You can build this up using the await this.resolve(newPathToFile, importer).

Once this function has received one of these values it will then move to the load function stage of compiling, this is where it will try to read the file and append it into the module. In this load stage we can do a number of things either providing the file we want or a completely new file. From here we can start to see how extensible this is.


In Webpack:

This is mostly performed in a similar standard inside of Webpack. Just like above, we will be using a function to hook into the import process and return back a value in which we tell the build system where this file is - except this time the 'return' is a callback. This is what it looks like:

compiler.hooks.normalModuleFactory.tap(PLUGIN_NAME, factory => {
factory.hooks.resolver.tap(PLUGIN_NAME, resolve => {
return (dep, callback) => {
// modify dep request
resolve(dep, callback);
}
})
});

Here there are a few more steps but it is roughly the same as explained here:

We take: compiler.hooks which is where all of our hooks into the webpack compiler live. There are more hooks in different namespaces such as: 'compilation hooks' and 'javascript hooks', but we will not need these for our example.

From there we look into normalModuleFactory which is a reference to modules in your local projects, or non-external modules. Continuing from here we .tap to be able to listen to events on this object and of this type. We then pull out the factory from the module with an anonymous function factory => {... and hook into a resolver with another .tap. The resolver in this case is a reference to when an import of a (normal)module occurs, to further listen to this we need to return a function which provides us access to these arguments:

  • dep: This dependency represents the metadata of both the file being imported and context info on the importer. Some useful variables are:
    • dep.context: the absolute path to the folder where this dependency is being imported from.
    • dep.contextInfo.issuer: the absolute path to the exact file which the dependency is being imported from.
    • dep.request: the exact string used to import the dependency from the importer. Usually relative but can be set to any string.
  • callback: this is the callback which will be run after the resolve has finished.
  • (from the resolver hook) resolve: resolve is a function that is to be called with the new dependency with either a new request or the existing dependency if it is not needed to change. This can also be called with the callback to let Webpack know this is finished.

Again - once considering how this resolves a path to the file, Webpack will go onto try to read or parse this file.


Next steps...

As we have seen the similarities between these two tools and how they hook into such an import. Given we understand that these two functions return instructions onto where to find such a file with the tools and APIs I have explained we can:

  • Add identifiers to imports to perform certain tasks, or resolve to separate paths.
    • import 'svg:path/to/file';
    • import '@service-worker-type'
  • Resolve certain file types which may not be initially readable by our JS code.
    • import jsonData from './path/to/data.json'
    • import imageNode from './path/to/image.jpg'
  • Add tags to imports to modify file that is being imported
    • import file from './path/to/file?tag=value'

Thanks for making it all this way. Please feel free to send me what you have built! I would love to check it out or provide some advice.