Implementing Webpack from Scratch, But in Rust - [4] Implement Plugin System

paradeto

ayou

Posted on November 5, 2024

Implementing Webpack from Scratch, But in Rust - [4] Implement Plugin System

Referencing mini-webpack, I implemented a simple webpack from scratch using Rust. This allowed me to gain a deeper understanding of webpack and also improve my Rust skills. It's a win-win situation!

Code repository: https://github.com/ParadeTo/rs-webpack

This article corresponds to the Pull Request: https://github.com/ParadeTo/rs-webpack/pull/5

We know that Webpack uses Tapable to implement its plugin system. So, it seems reasonable to write a Rust version inspired by it. However, implementing it turned out to be not as simple as expected. Let's take SyncHook as an example. A very simple version of it can be implemented in JavaScript like this:

class SyncHook {
  constructor() {
    this.taps = []
  }
  tap(options, fn) {
    this.taps.push(fn)
  }
  call() {
    this.taps.forEach((fn) => fn(...arguments))
  }
}

const hook = new SyncHook(['param1', 'param2']) // Create a hook object
hook.tap('event1', (param) => console.log('event1:', param))
hook.tap('event2', (param) => console.log('event2:', param))
hook.tap('event3', (param, param2) => console.log('event3:', param, param2))
hook.call('hello', 'world')
Enter fullscreen mode Exit fullscreen mode

Let's try to implement it in Rust. We might write code like this:

struct SyncHook<Arg: Copy, R> {
    taps: Vec<Box<dyn Fn(Arg) -> R>>
}

impl<Arg: Copy, R> SyncHook<Arg, R> {
    fn tap(&mut self, f: Box<dyn Fn(Arg) -> R>) {
        self.taps.push(f);
    }

    fn call(&self, a: Arg) {
        for tap in self.taps.iter() {
            tap(a);
        }
    }
}

fn main() {
    let mut sync_hook = &mut SyncHook{taps: vec![]};
    sync_hook.tap(Box::new(|arg| {
        println!("event {}", arg);
    }));
    sync_hook.call("hello")
}
Enter fullscreen mode Exit fullscreen mode

Note that Arg must be constrained to be Copy, otherwise calling tap(a) will result in an error. The above code can run correctly, but it only supports the case where call takes a single argument. However, this can be solved using macros. We can pre-generate a set of structs that support different numbers of arguments:

struct SyncHook1<Arg1: Copy, R> {
    taps: Vec<Box<dyn Fn(Arg1) -> R>>
}

struct SyncHook2<Arg1: Copy, Arg2: Copy, R> {
    taps: Vec<Box<dyn Fn(Arg1, Arg2) -> R>>
}
Enter fullscreen mode Exit fullscreen mode

However, there is still a problem: it does not support passing arguments of type &mut T:


struct Compiler {
    name: String
}

fn main() {
    let mut sync_hook = &mut SyncHook{taps: vec![]};
    sync_hook.tap(Box::new(|arg| {
        println!("event {}", arg);
    }));
    let compiler = &mut Compiler {name: String::from("test")};
    sync_hook.call(compiler)
}
Enter fullscreen mode Exit fullscreen mode

The above code will throw an error: "the trait Copy is not implemented for &mut Compiler".

It seems that implementing a similar functionality is quite challenging for me as a beginner. So, let's take a look at Rspack and see how they approach it.

Upon investigation, I found that Rspack does not have a generic SyncHook. Instead, it defines each hook separately using macros. Let's try to incorporate it into our project.

First, let's copy the code from Rspack's source code, specifically the crates/rspack_macros and crates/rspack_hook directories, into the crates directory of our rs-webpack project. Make sure to rename the directories accordingly.

├── crates
│   ├── rswebpack_hook
│   └── rswebpack_macros

Enter fullscreen mode Exit fullscreen mode

Then, let's add a new module called rswebpack_error as our unified error handling module:

// lib.rs
use anyhow::Result as AnyhowResult;

pub type Result<T> = AnyhowResult<T>;
Enter fullscreen mode Exit fullscreen mode

Finally, we need to modify the dependencies in these libraries accordingly. For example, in rswebpack_hook, change Result from rspack_error::Result to rswebpack_error::Result.

Let's write a demo to test it:

use rswebpack_macros::{define_hook, plugin, plugin_hook};

struct People {
    name: String
}

define_hook!(Test: SyncSeries(people: &mut People));

#[plugin]
struct TestHookTap1;

#[plugin_hook(Test for TestHookTap1)]
fn test1(&self, people: &mut People) -> Result<()> {
    people.name += " tap1";
    Ok(())
}

#[plugin]
struct TestHookTap2;

#[plugin_hook(Test for TestHookTap2)]
fn test2(&self, people: &mut People) -> Result<()> {
    people.name += " tap2";
    Ok(())
}

fn main() {
    let mut test_hook = TestHook::default();
    test_hook.tap(test1::new(&TestHookTap1::new_inner()));
    test_hook.tap(test2::new(&TestHookTap2::new_inner()));
    let people = &mut People { name: "ayou".into() };
    test_hook.call(people);
    println!("{}", people.name); // ayou tap1 tap2
}
Enter fullscreen mode Exit fullscreen mode

Let me explain the code above. First, we define a hook using define_hook!(Test: SyncSeries(people: &mut People));. It expands to something like this:

pub trait Test {
    fn run(&self, people: &mut People) -> rswebpack_hook::__macro_helper::Result<()>;
    fn stage(&self) -> i32 { 0 }
}
pub struct TestHook {
    taps: Vec<Box<dyn Test + Send + Sync>>,
    interceptors: Vec<Box<dyn rswebpack_hook::Interceptor<Self> + Send + Sync>>,
}
impl rswebpack_hook::Hook for TestHook {
    type Tap = Box<dyn Test + Send + Sync>;
    fn used_stages(&self) -> rswebpack_hook::__macro_helper::FxHashSet<i32> { rswebpack_hook::__macro_helper::FxHashSet::from_iter(self.taps.iter().map(|h| h.stage())) }
    fn intercept(&mut self, interceptor: impl rswebpack_hook::Interceptor<Self> + Send + Sync + 'static) { self.interceptors.push(Box::new(interceptor)); }
}
impl std::fmt::Debug for TestHook { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "TestHook") } }
impl Default for TestHook { fn default() -> Self { Self { taps: Default::default(), interceptors: Default::default() } } }
impl TestHook {
    pub fn call(&self, people: &mut People) -> rswebpack_hook::__macro_helper::Result<()> {
        let mut additional_taps = std::vec::Vec::new();
        for interceptor in self.interceptors.iter() { additional_taps.extend(interceptor.call_blocking(self)?); }
        let mut all_taps = std::vec::Vec::new();
        all_taps.extend(&self.taps);
        all_taps.extend(&additional_taps);
        all_taps.sort_by_key(|hook| hook.stage());
        for tap in all_taps { tap.run(people)?; }
        Ok(())
    }
    pub fn tap(&mut self, tap: impl Test + Send + Sync + 'static) { self.taps.push(Box::new(tap)); }
}
Enter fullscreen mode Exit fullscreen mode

You can see that TestHook is similar to our previous implementation, and it also generates the type Test for the tap function of TestHook.

Next, let's use a macro to implement a Tap for Test:

#[plugin]
struct TestHookTap1;

#[plugin_hook(Test for TestHookTap1)]
fn test1(&self, people: &mut People) -> Result<()> {
    people.name += " tap1";
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

It expands to something like this:

struct TestHookTap1 {
    inner: ::std::sync::Arc<TestHookTap1Inner>,
}
impl TestHookTap1 {
    fn new_inner() -> Self { Self { inner: ::std::sync::Arc::new(TestHookTap1Inner) } }
    fn from_inner(inner: &::std::sync::Arc<TestHookTap1Inner>) -> Self { Self { inner: ::std::sync::Arc::clone(inner) } }
    fn inner(&self) -> &::std::sync::Arc<TestHookTap1Inner> { &self.inner }
}
impl ::std::ops::Deref for TestHookTap1 {
    type Target = TestHookTap1Inner;
    fn deref(&self) -> &Self::Target { &self.inner }
}
#[doc(hidden)]
pub struct TestHookTap1Inner;

#[allow(non_camel_case_types)]
struct test1 {
    inner: ::std::sync::Arc<TestHookTap1Inner>,
}
impl test1 { fn new(plugin: &TestHookTap1) -> Self { test1 { inner: ::std::sync::Arc::clone(plugin.inner()) } } }
impl TestHookTap1 {
    #[allow(clippy::ptr_arg)]
    fn test1(&self, people: &mut People) -> Result<()> {
        people.name += " tap1";
        Ok(())
    }
}
impl ::std::ops::Deref for test1 {
    type Target = TestHookTap1Inner;
    fn deref(&self) -> &Self::Target { &self.inner }
}
impl Test for test1 {
    #[tracing::instrument(name = "TestHookTap1::test1", skip_all)]
    fn run(&self, people: &mut People) -> Result<()> {
        TestHookTap1::test1(&TestHookTap1::from_inner(&self.inner), people )
    }
}
Enter fullscreen mode Exit fullscreen mode

It may seem a bit convoluted at first, but with a few more readings, it becomes understandable.

In this way, we have implemented Tapable-like functionality in Rust. However, we have only demonstrated the usage of the simplest SyncHook for now. We will introduce other hooks in the future.

Next, let's build a plugin system based on this. As we know, implementing a plugin for webpack is typically done like this:

const pluginName = 'ConsoleLogOnBuildWebpackPlugin'

class ConsoleLogOnBuildWebpackPlugin {
  apply(compiler) {
    compiler.hooks.run.tap(pluginName, (compilation) => {
      console.log('The webpack build process is starting!')
    })
  }
}

module.exports = ConsoleLogOnBuildWebpackPlugin
Enter fullscreen mode Exit fullscreen mode

We follow the same pattern and start by defining a Plugin trait to specify the characteristics a plugin should have:

#[async_trait::async_trait]
pub trait Plugin: std::fmt::Debug {
    fn name(&self) -> &'static str {
        "unknown"
    }

    fn apply(&self, _ctx: PluginContext<&mut ApplyContext>) -> Result<()> {
        Ok(())
    }
}

#[derive(Debug, Default)]
pub struct PluginContext<T = ()> {
    pub context: T,
}

#[derive(Debug)]
pub struct ApplyContext<'c> {
    pub compiler_hooks: &'c mut CompilerHooks,
}

define_hook!(BeforeRun: SyncSeries(compiler: &mut Compiler));

#[derive(Default, Debug)]
pub struct CompilerHooks {
    pub before_run: BeforeRunHook,
}
Enter fullscreen mode Exit fullscreen mode

As you can see, we pass the before_run hook as context to the plugins.

Next, we define a PluginDriver to drive the plugins:

pub struct PluginDriver {
    pub plugins: Vec<Box<dyn Plugin>>,
    pub compiler_hooks: CompilerHooks,
}

impl PluginDriver {
    pub fn new(plugins: Vec<Box<dyn Plugin>>) -> Arc<Self> {
        let mut compiler_hooks = CompilerHooks::default();
        let mut apply_context = ApplyContext {
            compiler_hooks: &mut compiler_hooks,
        };

        for plugin in &plugins {
            plugin
                .apply(PluginContext::with_context(&mut apply_context))
                .expect("failed to apply plugin context");
        }

        Arc::new(Self {
            plugins,
            compiler_hooks,
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

Here, we initialize the parameters passed to each plugin, iterate over the plugins, and call their apply method.

Finally, the PluginDriver is used in the Compiler:

impl Compiler {
    pub fn new(mut config: Config, plugins: Vec<BoxPlugin>) -> Compiler {
        let plugin_driver = PluginDriver::new(plugins);

        Compiler {
            root: config.root.clone(),
            entry_id: "".to_string(),
            config,
            modules: HashMap::new(),
            assets: HashMap::new(),
            plugin_driver,
        }
    }
    ...
Enter fullscreen mode Exit fullscreen mode

We temporarily modify the run method of Compiler:

pub fn run(&mut self) {
    self.plugin_driver.clone().compiler_hooks.before_run.call(self);
    // let resolved_entry = Path::new(&self.root).join(&self.config.entry);
    // self.build_module(resolved_entry, true);
    // self.emit_file();
}
Enter fullscreen mode Exit fullscreen mode

Now, let's write a demo to test it:

use rswebpack_core::compiler::Compiler;
use rswebpack_core::config::{Config, Output};
use rswebpack_core::hooks::BeforeRun;
use rswebpack_core::plugin::{ApplyContext, Plugin, PluginContext};
use rswebpack_macros::{plugin, plugin_hook};
use rswebpack_error::Result;

#[plugin]
struct BeforeRunHookTap;

#[plugin_hook(BeforeRun for BeforeRunHookTap)]
fn before_run(&self, compiler: &mut Compiler) -> Result<()> {
    println!("Root is {}", compiler.root);
    Ok(())
}

#[derive(Debug)]
struct TestPlugin;

impl Plugin for TestPlugin {
    fn apply(&self, _ctx: PluginContext<&mut ApplyContext>) -> Result<()> {
        _ctx.context
            .compiler_hooks
            .before_run
            .tap(before_run::new(&BeforeRunHookTap::new_inner()));
        Ok(())
    }
}

fn main() {
    let config = Config::new(
        "test".to_string(),
        "test".to_string(),
        Output {
            path: "out".to_string(),
            filename: "bundle".to_string(),
        },
    );
    let compiler = &mut Compiler::new(config, vec![Box::new(TestPlugin {})]);
    compiler.run(); // Root is test
}
Enter fullscreen mode Exit fullscreen mode

There you have it! However, in actual development, custom plugins are usually developed in JavaScript. How can we integrate these JavaScript plugins? We'll reveal the answer in the next part.

Please kindly give me a star!

💖 💪 🙅 🚩
paradeto
ayou

Posted on November 5, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related