Native Plugins

How to write Parcel plugins in Rust, Go, Swift, or C

In addition to JavaScript, Parcel plugins can be written in Rust, Go, Swift, or any other language that can produce a shared library with a C ABI. Native plugins are compiled to a dynamic library (.dylib on macOS, .so on Linux, or .dll on Windows), which Parcel loads directly into its process.

Native plugins can act as transformers, resolvers, namers, optimizers, and reporters. A single library can implement any combination of these. The same rules described in Authoring Plugins apply: plugins should be stateless and pure.

When to use native plugins

#

JavaScript plugins are the easiest way to extend Parcel, and can use the huge ecosystem of existing compilers and tools on npm. However, they run in an embedded JavaScript engine, which is plenty fast for many types of infrequently used or simple plugins, but generally slower than native plugins for CPU-intensive work.

Native plugins are a good fit when:

Native plugins are called from multiple threads at once, so they must be thread safe. Errors returned by a plugin are reported as diagnostics.

Rust

#

The parcel-plugin crate is the Rust SDK for native plugins. Create a new library crate, and configure it to build a cdylib:

Cargo.toml:
[package]
name = "parcel-transformer-txt"
version = "0.1.0"
edition = "2024"

[lib]
crate-type = ["cdylib"]

[dependencies]
parcel-plugin = "0.1.0"
serde_json = "1"

A plugin is a struct that implements the Plugin trait, exported with the register_plugin! macro. The trait has one method for each plugin type (transform, resolve, name, and optimize), and one for each type of event a reporter receives (build_start, build_success, build_failure, and log). Implement the ones your plugin supports. The default implementations return an error, except for the reporter methods, which do nothing. The new method is called once for each entry in the .parcelrc that refers to the plugin, and receives that entry's config as UTF-8 encoded JSON bytes (or an empty slice when there is no config). This allows the same plugin to be used multiple times with different configuration.

src/lib.rs:
use parcel_plugin::{Asset, Diagnostic, Options, Plugin, register_plugin};

struct TxtTransformer {
prefix: String,
}

impl Plugin for TxtTransformer {
fn new(config: &[u8]) -> Result<Self, Diagnostic> {
let prefix = if config.is_empty() {
String::new()
} else {
let config: serde_json::Value =
serde_json::from_slice(config).map_err(|e| Diagnostic::new(e.to_string()))?;
config["prefix"].as_str().unwrap_or_default().to_owned()
};

Ok(TxtTransformer { prefix })
}

fn transform(&self, asset: &mut Asset, _options: &Options) -> Result<(), Diagnostic> {
// Emit an ES module that default-exports the text content.
let text = format!("{}{}", self.prefix, asset.content()?);
let literal = serde_json::to_string(&text).map_err(|e| Diagnostic::new(e.to_string()))?;
asset.set_content(format!("export default {literal};\n"));
asset.set_type("js");
Ok(())
}
}

register_plugin!(TxtTransformer);

Build it with cargo build, which produces target/debug/libparcel_transformer_txt.dylib (or .so or .dll).

Return a Diagnostic to fail a call (see Diagnostics). Panics are caught and reported as errors rather than crashing Parcel. See the examples for a plugin of each type.

Go

#

The Go SDK lives in the plugin-go directory of the Parcel repo, as the github.com/parcel-bundler/parcel/plugin-go/v3 module:

go get github.com/parcel-bundler/parcel/plugin-go/v3@v3.0.0-alpha.1

A plugin is a main package that registers a plugin in an init function. The plugin implements one or more of the Transformer, Resolver, Namer, and Optimizer interfaces, each of which has one method: Transform, Resolve, Name, or Optimize. A reporter implements an interface for each type of event it receives: BuildStartReporter, BuildSuccessReporter, BuildFailureReporter, or LogReporter. The function passed to RegisterPlugin is called once for each entry in the .parcelrc that refers to the plugin, and receives that entry's config as UTF-8 encoded JSON bytes (or an empty slice when there is no config). This allows the same plugin to be used multiple times with different configuration.

main.go:
package main

import (
"encoding/json"
"fmt"

parcel "github.com/parcel-bundler/parcel/plugin-go/v3"
)

type TxtTransformer struct {
Prefix string `json:"prefix"`
}

func (t *TxtTransformer) Transform(asset *parcel.Asset, _ *parcel.Options) error {
content, err := asset.Content()
if err != nil {
return err
}

// Emit an ES module that default-exports the text content.
// A JSON string is a valid JavaScript string literal.
literal, err := json.Marshal(t.Prefix + content)
if err != nil {
return err
}
asset.SetContent(fmt.Sprintf("export default %s;\n", literal))
asset.SetType("js")
return nil
}

func init() {
parcel.RegisterPlugin(func(config []byte) (any, error) {
t := &TxtTransformer{}
if len(config) > 0 {
if err := json.Unmarshal(config, t); err != nil {
return nil, err
}
}
return t, nil
})
}

func main() {}

Build it as a C shared library. This requires cgo, and therefore a C compiler.

go build -buildmode=c-shared -o plugin.dylib .

Return an error, e.g. a *parcel.Diagnostic, to fail a call (see Diagnostics). Panics are recovered and reported as errors rather than crashing Parcel. See the examples for a plugin of each type.

Swift

#

The Swift SDK lives in the plugin-swift directory of the Parcel repo, and provides the ParcelPlugin library. It requires Swift 6.2 or later, and macOS 15 or later at runtime. Declare a dynamic library product in your Package.swift:

Package.swift:
// swift-tools-version: 6.2
import PackageDescription

let package = Package(
name: "TxtTransformer",
platforms: [.macOS(.v15)],
products: [.library(name: "TxtTransformer", type: .dynamic, targets: ["TxtTransformer"])],
dependencies: [.package(url: "https://github.com/parcel-bundler/parcel", exact: "3.0.0-alpha.1")],
targets: [
.target(
name: "TxtTransformer",
dependencies: [.product(name: "ParcelPlugin", package: "parcel")])
]
)

A plugin conforms to one or more of the Transformer, Resolver, Namer, Optimizer, and Reporter protocols, and is exported with the #parcelPlugin macro. The plugin is created once for each entry in the .parcelrc that refers to it, and its Config is decoded from that entry's config using JSONDecoder. This allows the same plugin to be used multiple times with different configuration. Plugins without config declare init(config: NoConfig).

Sources/TxtTransformer/TxtTransformer.swift:
import Foundation
import ParcelPlugin

struct TxtTransformer: Transformer {
struct Config: Decodable {
var prefix: String?
}

let prefix: String

init(config: Config) {
prefix = config.prefix ?? ""
}

func transform(_ asset: Asset, options: Options) async throws {
// Emit an ES module that default-exports the text content.
// A JSON string is a valid JavaScript string literal.
let literal = try JSONEncoder().encode(prefix + asset.content())
asset.setContent("export default \(String(decoding: literal, as: UTF8.self));\n")
asset.type = "js"
}
}

#parcelPlugin(TxtTransformer.self)

Build it with swift build -c release, which produces .build/release/libTxtTransformer.dylib.

Throw an error, e.g. a Diagnostic, to fail a call (see Diagnostics). Unlike Rust and Go, Swift cannot recover from a trap (e.g. a failed force unwrap), which crashes Parcel, so throw errors instead. Every plugin method is async, and plugins must be Sendable since one instance is called from many threads at once. See the README for more details about concurrency, and the examples for a plugin of each type.

File system

#

Every plugin method receives an Options object, which provides the project root, environment variables, logging, and access to the file system. Plugins should read files through Options, rather than directly with the language's own file system APIs. This way, Parcel knows which files the plugin depends on, and re-runs it when one of them changes or a missing file is created. These methods are available while transforming or resolving.

read_file() reads a file, file_kind() returns whether a path is a file, directory, or does not exist, and canonicalize() resolves symlinks.

let config = options
.read_to_string("/path/to/tool.config.json")
.map_err(|e| Diagnostic::new(e.to_string()))?;

C ABI

#

All of the SDKs are built on a C ABI, which is defined in plugin.h. Plugins in other languages can implement it directly. A plugin library exports the following functions:

Plugins don't link against any Parcel symbols. Instead, all calls into Parcel go through the table of host functions passed to parcel_plugin_init, which plugin.h wraps in inline functions such as parcel_asset_get_content and parcel_asset_set_content.

For example, this transformer converts a file into a JavaScript module that exports its number of lines. Copy plugin.h into your project, and include it:

plugin.c:
#include <stdio.h>
#include <string.h>
#include "plugin.h"

// The host functions passed to parcel_plugin_init. The inline
// functions in plugin.h call through this.
const struct ParcelApi *parcel_api;

InitStatus parcel_plugin_init(const struct ParcelApi *api, const uint8_t *config,
uintptr_t config_len, void **state,
struct DiagnosticOptions *diagnostic) {
if (!parcel_api_compatible(&api->header)) {
return PARCEL_INIT_INCOMPATIBLE;
}
parcel_api = api;
return PARCEL_INIT_OK;
}

// Converts a file into a JavaScript module that exports its number of lines.
void parcel_plugin_transform(Asset asset, Options options, void *state,
struct DiagnosticOptions *diagnostic) {
struct Buffer content = {0};
Diagnostics diagnostics = 0;
if (!parcel_asset_get_content(&content, asset, options, &diagnostics)) {
// Fail with the diagnostics Parcel reported for the read.
const char *message = "Could not read the asset.";
diagnostic->message = parcel_buffer_alloc((const uint8_t *)message, strlen(message));
diagnostic->diagnostics = diagnostics;
return;
}

size_t lines = 0;
for (uintptr_t i = 0; i < content.len; i++) {
if (content.data[i] == '\n') {
lines++;
}
}
if (content.len > 0 && content.data[content.len - 1] != '\n') {
lines++;
}
parcel_buffer_free(&content);

char code[64];
int len = snprintf(code, sizeof(code), "export default %zu;\n", lines);
parcel_asset_set_content_utf8(asset, (const uint8_t *)code, len, NULL, 0);
parcel_asset_set_type(asset, (const uint8_t *)"js", 2);
}

Then build it as a shared library with your C compiler, e.g. cc -shared -fPIC -o plugin.dylib plugin.c.

The ABI is versioned with PARCEL_ABI_VERSION. A plugin built against a different ABI version than the one Parcel implements is rejected with an error, so it needs to be rebuilt.

Local development

#

While developing a native plugin, you can reference the built library by its relative path in .parcelrc. Paths ending in .dylib, .so, or .dll are loaded as native plugins.

.parcelrc:
{
"extends": "@parcel/config-default",
"transformers": {
"*.txt": [
{
"plugin": "./target/debug/libparcel_transformer_txt.dylib",
"config": {
"prefix": "Hello: "
}
}
]
}
}

Native plugins can also be referenced by package name, the same way they are when they are installed from npm. To do this, set the parcel.devLibrary field in the plugin's package.json (alongside parcel.artifacts, described below) to the path of your local build. The file extension may be omitted, in which case Parcel appends the extension for the current platform. This way, the same entry works on every platform.

package.json:
{
"name": "parcel-transformer-txt",
"version": "1.0.0",
"parcel": {
"abi": 1,
"artifacts": {
"aarch64-apple-darwin": "parcel-transformer-txt-darwin-arm64",
"x86_64-unknown-linux-gnu": "parcel-transformer-txt-linux-x64-gnu"
},
"devLibrary": "./target/debug/libparcel_transformer_txt"
}
}

When devLibrary is set, it is always used instead of the published binaries. If the file does not exist, the build fails. devLibrary is removed when the plugin is published using the workflow described below.

Publishing

#

Native plugins are published to npm, and installed and referenced in .parcelrc just like JavaScript plugins. They follow the same naming conventions.

A native plugin is published as one package per platform, each containing the shared library for that platform, along with a main package that only contains metadata. The main package's package.json lists the package for each platform under parcel.artifacts, keyed by Rust target triple, and declares the ABI version the plugin was built against in parcel.abi. The platform packages are listed as optionalDependencies, so npm only installs the one matching the user's platform. Each platform package names its library in parcel.library.

package.json:
{
"name": "parcel-transformer-txt",
"version": "1.0.0",
"files": [],
"parcel": {
"abi": 1,
"artifacts": {
"aarch64-apple-darwin": "parcel-transformer-txt-darwin-arm64",
"x86_64-apple-darwin": "parcel-transformer-txt-darwin-x64",
"x86_64-pc-windows-msvc": "parcel-transformer-txt-win32-x64-msvc",
"x86_64-unknown-linux-gnu": "parcel-transformer-txt-linux-x64-gnu",
"aarch64-unknown-linux-gnu": "parcel-transformer-txt-linux-arm64-gnu",
"x86_64-unknown-linux-musl": "parcel-transformer-txt-linux-x64-musl",
"aarch64-unknown-linux-musl": "parcel-transformer-txt-linux-arm64-musl"
}
}
}

An artifact may also be a relative path to a library inside the main package itself, e.g. "./plugin-darwin-arm64.dylib". This ships all platforms in a single package, which is simpler for small plugins, at the cost of every user downloading every binary.

Parcel provides a reusable GitHub Actions workflow that builds a Rust, Go, or Swift plugin for every platform listed in parcel.artifacts, generates the platform packages, and publishes everything to npm using trusted publishing.

.github/workflows/release.yml:
name: release

on:
push:
tags: ["v*"]

jobs:
release:
permissions:
contents: read
id-token: write # required for npm trusted publishing
uses: parcel-bundler/parcel/.github/workflows/native-plugin.yml@v3.0.0-alpha.1
with:
paths: .
publish: true

When called with publish: false (the default), it builds the plugin and uploads the packages as workflow artifacts without publishing, which is useful as a check on pull requests. See the workflow documentation for all of the options and supported platforms.