In a previous post, we covered how to migrate an existing webpack project from Babel to Speedy Web Compiler (SWC). SWC is a high-performance JavaScript/TypeScript compiler written in Rust, with the goal of speeding up existing build pipelines for modern frontend web projects.
In this article, we’ll look at how to write custom plugins for SWC, which can then be used in webpack builds. By migrating your existing custom webpack and Babel plugins to Rust, you’ll be able to make your webpack builds even faster.
Before we jump in, there is a small disclaimer. At the time of writing, the SWC plugin system is still experimental. If you plan to use your custom SWC plugins in an actual project, you should be prepared to deal with some API instability and potential breaking changes. The APIs described here may change in the upcoming months and years, but the concepts and general approach shared in this article should remain valid.
In this tutorial, we’ll create a simple custom Rust-based SWC plugin and compile it to Wasm, so it can be used in a webpack build using swc-loader
. We’ll build our simple plugin and import it in a JavaScript project using webpack, configure it as a plugin running with the swc-loader
within webpack, and then check that the plugin was run and that it worked.
Let’s get started!
Jump ahead:
For the purposes of this tutorial, we’ll build a very simple plugin that will transform console.log
statements in the codebase to console.debug
statements. This plugin may not be particularly useful in real life, but it’s a solid example to demonstrate basic concepts, rather than trying to show how to use SWC to build complex Abstract Syntax Tree (AST)-transformation plugins.
If you’re interested in learning how to build more complex plugins, I recommend checking out the SWC docs.
Let’s start by installing the SWC command line interface:
cargo install swc_cli
There is currently a bug in a dependency, so let’s use Rust nightly-2022-09-23
to build our plugin. We’ll use the below commands to install this version of Rust and set it as the default:
rustup install nightly-2022-09-23 rustup default nightly-2022-09-23-x86_64-unknown-linux-gnu
Now, let’s add a Wasm compile target:
rustup target add wasm32-wasi
Next, we’ll create a new SWC plugin project, like so:
swc plugin new --target-type wasm32-wasi rust-swc-webpack-plugin
This will create a rust-swc-webpack-plugin
folder and, inside the folder, it will initialize everything we need to start building our plugin.
For this tutorial, we’ll use swc_core
v.0.69. At the time of writing, the Wasm-based SWC plugins are not backward compatible, so it’s important that the swc_core
version used in our Rust-based plugin is compatible with the swc/core
version that will be used in the JavaScript project later on:
swc_core = { version = "0.69.*", features = ["common", "ecma_utils", "ecma_plugin_transform"] }
The SWC CLI creates a src/lib.rs
file with some available template code. It also sets up all the dependencies and other items relevant to the build process.
Check out the Cargo.toml
file, as well as the src/lib.rs
file and package.json
created by the CLI before moving on, to get a better idea of the starting point for building an SWC plugin.
Let’s start implementing our custom SWC plugin!
As a first step, let’s take a look in the lib.rs
file:
use swc_core::ecma::{ ast::Program, transforms::testing::test, visit::{as_folder, FoldWith, VisitMut}, }; use swc_core::plugin::{plugin_transform, proxies::TransformPluginProgramMetadata}; pub struct TransformVisitor; impl VisitMut for TransformVisitor { }
We have the basic parts of swc_core
imported, but we need to implement the VisitMut
trait for a given struct called TransformVisitor
. With the VisitMut
trait, we can implement many different methods, which will be called when the AST is traversed. In our example, we’ll implement visit_mut_call_expr
, which will be called whenever a call expression, such as console.log()
, is encountered.
The above code also includes a plugin_transform
function and macro; these are some of the basic “machinery” used to wire the plugin up correctly.
We also have a default test harness, so we can use cargo test
to test our plugin:
test!( Default::default(), |_| as_folder(TransformVisitor), boo, // Input codes r#"console.log("transform");"#, // Output codes after transformed with plugin r#"console.log("transform");"# );
From this base, we can start implementing our custom SWC plugin. First, let’s introduce some constants:
const CONSOLE: &str = "console"; const DEBUG: &str = "debug"; const LOG: &str = "log";
Next, we’ll implement our actual plugin logic:
impl VisitMut for TransformVisitor { noop_visit_mut_type!(); fn visit_mut_call_expr(&mut self, call_expr: &mut CallExpr) { call_expr.visit_mut_children_with(self); if let Callee::Expr(callee) = &mut call_expr.callee { if let Expr::Member(member) = &**callee { if let (Expr::Ident(obj), MemberProp::Ident(prop)) = (&*member.obj, &member.prop) { if &obj.sym == CONSOLE && &prop.sym == LOG { *callee = Box::new(Expr::Member(MemberExpr { span: DUMMY_SP, obj: member.obj.to_owned(), prop: MemberProp::Ident(quote_ident!(DEBUG)), })); } } } } } }
In the above code, noop_visit_mut_type!();
simply indicates that TypeScript types don’t need to be considered, which reduces the binary size of the plugin.
Then we implement the visit_mut_call_expr
method of the VisitMut
trait, which will be called for every call expression encountered in the AST.
We use visit_mut_children_with
to ensure that our logic is also called for all children nodes of the current node.
Then we implement the actual logic of our plugin. We start by drilling down the AST of the call expression, by getting the callee
of the expression, and by splitting the expression up into the thing we call something on (obj
) and the thing we call (prop
).
With these two elements (obj
and prop
), we can check that we only run our logic if we call log
on console
. If this is the case, we update the callee
to use the debug
call, instead of the log
call.
To stay with the scope of this post, I won’t go into too much detail on the types, such as MemberExpr
, Expr::Ident
, etc., representing the AST involved here. For more information, check out the official docs.
That’s quite a bit of code for changing a simple property, but keep in mind that we’re literally manipulating the AST while traversing it. In addition, we’re dealing with the entire complexity of ECMAScript and TypeScript.
Hopefully we’ve implemented our logic correctly! Let’s check with some testing.
We’ll use the test-harness macro to create some test cases for our plugin:
test!( Default::default(), |_| as_folder(TransformVisitor), log_to_debug, // Input codes r#"console.log("hello, world");"#, // Output codes after transformed with plugin r#"console.debug("hello, world");"# ); test!( Default::default(), |_| as_folder(TransformVisitor), debug_stays_debug, // Input codes r#"console.debug("hello, world");"#, // Output codes after transformed with plugin r#"console.debug("hello, world");"# ); test!( Default::default(), |_| as_folder(TransformVisitor), not_interested_in_args, // Input codes r#"console.debug("log");"#, // Output codes after transformed with plugin r#"console.debug("log");"# );
We can use cargo test
to test our SWC plugin. If everything was implemented correctly, we should see the following output:
running 3 tests test log_to_debug ... ok test not_interested_in_args ... ok test debug_stays_debug ... ok test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
Great — things seem to be working!
Now that we’ve confirmed that everything is working properly, let’s build our plugin using the following command:
cargo build-wasi --release
This will create our Wasm file at target/wasm32-wasi/release/rust_swc_webpack_plugin.wasm
. This is the point where we could actually publish our plugin to npm, so that it could be used conveniently in projects.
With swc-loader
, we can use this Wasm file in our webpack configuration in order to use our new plugin in a webpack build.
Now it’s time to test our SWC plugin with webpack.
Let’s start by setting up a basic JavaScript project with webpack by running npm init
followed by npm i --save-dev @swc/core swc-loader webpack webpack-cli
in a new folder.
In this example, let’s use the below versions of the various packages. As mentioned previously, the @swc/core
version is especially important since it has to match up with the swc_core
version in our Rust project:
{ ... "devDependencies": { "@swc/core": "^1.3.39", "swc-loader": "^0.2.3",is already "webpack": "^5.77.0", "webpack-cli": "^5.0.1" } ... }
Next, we’ll create an src
folder and add a file, called index.js
, inside it with the following content:
console.log("hello, world"); console.debug("this is already debuged. "); console.debug("log"); console.trace("hello");
Here, we simply add a couple of console.log
statements, so that we can test if our plugin runs and does its job properly.
Next, we’ll create a file called webpack.config.js
in the root of the project. This file will hold our webpack config.
In the webpack config, we’ll start with the basics:
module.exports = { name: 'swc-plugin-test', mode: "production", entry: path.join(__dirname, "src", "index.js"), output: { path: path.join(__dirname, "build"), filename: "bundle.js" }, }
Here we set a name for the config, set the mode
to production
, and set the entry
point and output
for the webpack build — basic stuff. Now let’s look at the interesting stuff — how to configure swc-loader
and a custom plugin:
... module: { rules: [ { test: /\.js$/, use: [ { loader: 'swc-loader', options: { jsc: { parser: { syntax: 'ecmascript' }, experimental: { plugins: [["/file/path/to/rust-swc-webpack-plugin/target/wasm32-wasi/release/rust_swc_webpack_plugin.wasm", {}]] }, }, }, } ], exclude: /node_modules/, }, ], }, }
Here we define a module
for all .js
files. We also specify that swc-loader
should be used as a loader
and that it should run jsc
to compile our ecmascript
. We could also use TypeScript here if we were using that language in our project.
Next, we add our plugin. As you can see, this is in the experimental
part of the config. Within experimental
, we can define plugins
and then simply add a tuple with the filesystem path to the Wasm file we previously built in our Rust plugin.
Here, we could also just add an existing plugin from npm. For example, we could use loadable-components
SWC plugin from npm instead of the path to our Wasm file.
We also add a basic index.html
file:
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <title>webpack Plugin Test</title> </head> <body> <script src="./src/index.js"></script> </body> </html>
That’s it!
Now let’s use the following command to run webpack:
./node_modules/.bin/webpack
You should see output similar to this:
asset bundle.js 113 bytes \[compared for emit\] [minimized] (name: main) ./src/index.js 117 bytes \[built\] [code generated] swc-plugin-test (webpack 5.77.0) compiled successfully in 290 ms
Now, let’s check the output in build/bundle.js
, which is our index.js
file compiled with our plugin:
console.debug("hello, world"),console.debug("this is already debuged"),console.debug("log"),console.trace("hello");
All console.log
statements were replaced with console.debug
, but the console.trace
was not touched. Our plugin was called and it worked — success!
In this article, we explored how to build custom SWC plugins using Rust and then use them in webpack-based JavaScript or TypeScript projects to improve performance by speeding up our builds.
The plugin system described in this tutorial is still experimental and will likely change and mature. However, at the time of writing it already works quite smoothly. It’s already possible to port complex Babel plugins to SWC by replicating the AST-traversal and modification logic and porting it to Rust.
If you’re interested in diving deeper into SWC plugin development, you can also check out the SWC Playground and the SWC Viewer project. These are both quite helpful resources for building SWC plugins.
SWC is an exciting project that was sorely needed in the bloated space of frontend build pipelines, providing the possibility to rebuild and rethink some of the existing machinery, while improving performance by orders of magnitude.
The examples used in this tutorial are available on GitHub.
Debugging Rust applications can be difficult, especially when users experience issues that are hard to reproduce. If you’re interested in monitoring and tracking the performance of your Rust apps, automatically surfacing errors, and tracking slow network requests and load time, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your Rust application. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app’s performance, reporting metrics like client CPU load, client memory usage, and more.
Modernize how you debug your Rust apps — start monitoring for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowThe useReducer React Hook is a good alternative to tools like Redux, Recoil, or MobX.
Node.js v22.5.0 introduced a native SQLite module, which is is similar to what other JavaScript runtimes like Deno and Bun already have.
Understanding and supporting pinch, text, and browser zoom significantly enhances the user experience. Let’s explore a few ways to do so.
Playwright is a popular framework for automating and testing web applications across multiple browsers in JavaScript, Python, Java, and C#. […]