WASM plugin
If you want more control over how your tool works, a WASM plugin is the way to go.
Refer to our official WASM guide for more information on how our WASM plugins work, critical concepts to know, and more. Once you have a good understanding, you may continue this proto specific guide.
Concepts
The following concepts are unique to proto, but be sure to also read about the general concepts in our WASM plugins guide.
Tool context
For plugin functions, we provide what we call the tool context, which is information that is
constantly changing depending on the current step or state of proto's execution. The context cannot
be accessed with a stand-alone function, and is instead passed as a context
field in the input of
many plugin functions.
#[plugin_fn]
pub fn download_prebuilt(Json(input): Json<DownloadPrebuiltInput>) -> FnResult<Json<DownloadPrebuiltOutput>> {
let version = input.context.version;
// ...
}
The following fields are available on the context object:
proto_version
- The version of proto executing the plugin. Note that this version may be for theproto_core
crate, and not the CLI. Patch numbers will drift, but major and minor numbers should be in sync.tool_dir
- A virtual path to the tool's directory for the current version.version
- The current version or alias. If not resolved, will be "latest".
The version
field is either a fully-qualified version (1.2.3), an alias ("latest", "stable"), or
canary ("canary"). Be sure to account for all these variations when implementing plugin functions!
Tool configuration
Users can configure tools through the [tools.*]
section of their .prototools
,
which can then be accessed within the WASM plugin using the
get_tool_config
function.
let config = get_tool_config::<NodeConfig>()?;
config.bundled_npm;
This function requires a struct to deserialize into. It should implement Default
, enable serde
defaults, and map keys from kebab-case
. If you want to error on unknown settings, also enable
deny_unknown_fields
.
#[derive(Debug, Default, serde::Deserialize)]
#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
struct NodeConfig {
pub bundled_npm: bool,
pub intercept_globals: bool,
}
Creating a plugin
Refer to our official WASM guide for steps on how to create a Rust based plugin.
Implementing plugin functions
Plugins are powered by a set of functions that are called from the host, and are annotated with
#[plugin_fn]
.
Registering metadata
The first step in a plugin's life-cycle is to register metadata about the plugin with the
register_tool
function. This function is called immediately after a plugin is loaded at runtime,
and must return a human-readable name and plugin type.
#[plugin_fn]
pub fn register_tool(_: ()) -> FnResult<Json<ToolMetadataOutput>> {
Ok(Json(ToolMetadataOutput {
name: "Node.js".into(),
type_of: PluginType::Language,
minimum_proto_version: Some(Version::new(0, 42, 0)),
plugin_version: Version::parse(env!("CARGO_PKG_VERSION")).ok(),
..ToolMetadataOutput::default()
}))
}
This function also receives the plugin ID as input, allowing for conditional logic based on the ID.
The ID is the key the plugin was configured with, and what is passed
to proto
commands (e.g. proto install <id>
).
#[plugin_fn]
pub fn register_tool(Json(input): Json<ToolMetadataInput>) -> FnResult<Json<ToolMetadataOutput>> {
input.id
// ...
}
Configuration schemav0.40.0
If you are using tool configuration, you can register the shape of the
configuration using the schematic
crate. This shape will be
used to generate outputs such as JSON schemas, or TypeScript types.
#[plugin_fn]
pub fn register_tool(_: ()) -> FnResult<Json<ToolMetadataOutput>> {
Ok(Json(ToolMetadataOutput {
// ...
config_schema: Some(schematic::SchemaBuilder::generate::<NodeConfig>()),
}))
}
Schematic is a heavy library, so we suggest adding the dependency like so:
[dependencies]
schematic = { version = "*", default-features = false, features = ["schema"] }
Downloading pre-builts
Our plugin layer only supports downloading pre-built tools, typically as an archive, and does
not support building from source. The download_prebuilt
function must be defined, whichs
configures how the tool should be downloaded and installed.
The following fields are available:
archive_prefix
- If the tool is distributed as an archive (zip, tar, etc), this is the name of the direct folder within the archive that contains the tool, and will be removed when unpacking the archive. If there is no prefix folder within the archive, this setting can be omitted.download_url
(required) - A secure URL to download the tool/archive.download_name
- File name of the archive to download. If not provided, will attempt to extract it from the URL.checksum_url
- A secure URL to download the checksum file for verification. If the tool does not support checksum verification, this setting can be omitted.checksum_public_key
- Public key used for verifying checksums. Only used for.minisig
files.
#[plugin_fn]
pub fn download_prebuilt(Json(input): Json<DownloadPrebuiltInput>) -> FnResult<Json<DownloadPrebuiltOutput>> {
let env = get_host_environment()?;
check_supported_os_and_arch(
NAME,
&env,
permutations! [
HostOS::Linux => [HostArch::X64, HostArch::Arm64, HostArch::Arm, HostArch::Powerpc64, HostArch::S390x],
HostOS::MacOS => [HostArch::X64, HostArch::Arm64],
HostOS::Windows => [HostArch::X64, HostArch::X86, HostArch::Arm64],
],
)?;
let version = input.context.version;
let arch = env.arch;
let os = env.os;
let prefix = match os {
HostOS::Linux => format!("node-v{version}-linux-{arch}"),
HostOS::MacOS => format!("node-v{version}-darwin-{arch}"),
HostOS::Windows => format!("node-v{version}-win-{arch}"),
other => {
return Err(PluginError::UnsupportedPlatform("Node.js".into(), other.into()))?;
}
};
let filename = if os == HostOS::Windows {
format!("{prefix}.zip")
} else {
format!("{prefix}.tar.xz")
};
Ok(Json(DownloadPrebuiltOutput {
archive_prefix: Some(prefix),
download_url: format!("https://nodejs.org/dist/v{version}/{filename}"),
download_name: Some(filename),
checksum_url: Some(format!("https://nodejs.org/dist/v{version}/SHASUMS256.txt")),
..DownloadPrebuiltOutput::default()
}))
}
Unpacking an archive
Our plugin layer will do its best to detect file extensions, unpack the downloaded file (if an
archive), and install the tool to the correct directory. However, we're unable to account for all
edge cases, so for situations where the install params above are not sufficient, you may define an
unpack_archive
function.
This function receives an input with the following fields:
input_file
- Virtual path to the downloaded file. Maps to~/.proto/temp/<id>/<file>
.output_dir
- Virtual directory to unpack the archive into, or copy the binary to. Maps to~/.proto/tools/<id>/<version>
.
#[plugin_fn]
pub fn unpack_archive(Json(input): Json<UnpackArchiveInput>) -> FnResult<()> {
untar(input.input_file, input.output_dir)?;
Ok(())
}
Locating executables
Even though a tool has been installed, we must inform proto of where to find executables. This can
be achieved with the required locate_executables
function. The exes
field defines the location
of the executables, relative from the installation directory.
#[plugin_fn]
pub fn locate_executables(
Json(_): Json<LocateExecutablesInput>,
) -> FnResult<Json<LocateExecutablesOutput>> {
let env = get_host_environment()?;
Ok(Json(LocateExecutablesOutput {
exes: HashMap::from_iter([
// Primary
(
"node".into(),
ExecutableConfig::new_primary(
// Helper that chooses between distinct Unix or Windows values
env.os.for_native("bin/node", "node.exe"),
// Or the same value with optional Windows extension
// env.os.get_file_name("node", "exe")
)
),
// Secondary
(
"corepack".into(),
ExecutableConfig::new(env.os.for_native("bin/corepack", "corepack.exe"))
),
]),
..LocateExecutablesOutput::default()
}))
}
The main executable of the tool must be marked as primary, either with
ExecutableConfig::new_primary
, or setting theExecutableConfig.primary
field to true.
Furthermore, the locate_executables
function can define a list of lookups for the globals
installation directory. proto will loop through each lookup, and return the first directory that
exists on the current file system. proto will also expand environment variables in the format of
$VAR_NAME
. If a variable is not defined, or has an empty value, the lookup will be skipped. To
demonstrate this, we'll use Deno.
#[plugin_fn]
pub fn locate_executables(
Json(_): Json<LocateExecutablesInput>,
) -> FnResult<Json<LocateExecutablesOutput>> {
let env = get_host_environment()?;
Ok(Json(LocateExecutablesOutput {
globals_lookup_dirs: vec!["$DENO_INSTALL_ROOT/bin".into(), "$HOME/.deno/bin".into()],
// ...
..LocateExecutablesOutput::default()
}))
}
Loading and resolving versions
Now that the tool can be downloaded and installed, we must configure how to resolve available
versions to actually be installed. To provide a list of versions and language specific aliases, the
load_versions
function must be defined.
#[plugin_fn]
pub fn load_versions(Json(_): Json<LoadVersionsInput>) -> FnResult<Json<LoadVersionsOutput>> {
let mut output = LoadVersionsOutput::default();
let response: Vec<NodeDistVersion> = fetch_json("https://nodejs.org/dist/index.json")?;
for (index, item) in response.iter().enumerate() {
let version = VersionSpec::parse(&item.version[1..])?; // Starts with v
if index == 0 {
output.latest = Some(version.clone());
}
output.versions.push(version);
}
Ok(Json(output))
}
Furthermore, we support an optional function named resolve_version
, that can be defined to
intercept the version resolution process. This function receives an input with an initial candidate,
either an alias or version, and can replace it with a new candidate. The candidate must be a valid
alias or version as defined in load_versions
.
#[plugin_fn]
pub fn resolve_version(
Json(input): Json<ResolveVersionInput>,
) -> FnResult<Json<ResolveVersionOutput>> {
let mut output = ResolveVersionOutput::default();
if let UnresolvedVersionSpec::Alias(alias) = input.initial {
let candidate = if alias == "node" {
"latest"
} else if alias == "lts-*" || alias == "lts/*" {
"stable"
} else if alias.starts_with("lts-") || alias.starts_with("lts/") {
&alias[4..]
} else {
return Ok(Json(output));
};
output.candidate = Some(UnresolvedVersionSpec::Alias(candidate.to_owned()));
}
Ok(Json(output))
}
Detecting versions
And lastly, we can configure how to detect a version contextually at runtime, using
the detect_version_files
function and optional parse_version_file
function. The
detect_version_files
function can return a list of files to locate within a directory.
#[plugin_fn]
pub fn detect_version_files(_: ()) -> FnResult<Json<DetectVersionOutput>> {
Ok(Json(DetectVersionOutput {
files: vec![
".nvmrc".into(),
".node-version".into(),
"package.json".into(),
],
ignore: vec!["node_modules".into()],
}))
}
By default our plugin layer will assume the version file's contents contain the literal version, and
nothing else, like "1.2.3". If any of the files in the detect_version_files
list require custom
parsing (for example, package.json
above), you can define the parse_version_file
function.
This function receives the file name and contents as input, and must return the parsed version (if applicable).
#[plugin_fn]
pub fn parse_version_file(Json(input): Json<ParseVersionFileInput>) -> FnResult<Json<ParseVersionFileOutput>> {
let mut version = None;
if input.file == "package.json" {
let json: PackageJson = serde_json::from_str(&input.content)?;
if let Some(engines) = json.engines {
if let Some(constraint) = engines.get("node") {
version = Some(UnresolvedVersionSpec::parse(constraint)?);
}
}
} else {
version = Some(UnresolvedVersionSpec::parse(input.content.trim())?);
}
Ok(Json(ParseVersionFileOutput { version }))
}
Testing
The best way to test the plugin is to execute it through proto
directly. To do this, you'll need
to configure a .prototools
file at the root of your plugin's repository that maps the plugin to a
debug build:
[plugins]
<id> = "file://./target/wasm32-wasi/debug/<name>.wasm"
And everytime you make a change to the plugin, you'll need to rebuild it with:
cargo wasi build
With these 2 pieces in place, you can now execute proto
commands. Be sure you're running them from
the directory with the .prototools
file, and that you're passing --log trace
. Logs are extremely
helpful for figuring out what's going on.
proto --log trace install <id>
proto --log trace list-remote <id>
...
Unit tests
Testing WASM plugins is a bit tricky, but we've taken it upon ourselves to streamline this process as much as possible with built-in test utilities, and Rust macros for generating common test cases. To begin, install all necessary development dependencies:
cargo add --dev proto_pdk_test_utils starbase_sandbox tokio
And as mentioned above, everytime you make a change to the plugin, you'll need to rebuild it with:
cargo wasi build
Testing plugin functions
The common test case is simply calling plugin functions with a provided input and asserting the
output is correct. This can be achieved by creating a plugin instance with create_plugin
and
calling the appropriate method.
use proto_pdk_test_utils::*;
use starbase_sandbox::create_empty_sandbox;
#[tokio::test(flavor = "multi_thread")]
async fn registers_metadata() {
let sandbox = create_empty_proto_sandbox();
let plugin = sandbox.create_plugin("id").await;
assert_eq!(
plugin.register_tool(ToolMetadataInput::default()).await,
ToolMetadataOutput {
name: "Name".into(),
..ToolMetadataOutput::default()
}
);
}
We suggest using this pattern for static functions that return a deterministic output from a provided input, and not for dynamic functions that make HTTP requests or execute host commands.
Generating cases from macros
To reduce the burden of writing custom tests for common flows, like downloading a pre-built, resolving versions, and generating shims, we provide a set of Rust decl macros that will generate the tests for you.
To test downloading and installing, use generate_download_install_tests!
. This macro requires a
plugin ID and a real version to test with.
use proto_pdk_test_utils::*;
generate_download_install_tests!("id", "1.2.3");
To test version resolving, use generate_resolve_versions_tests!
. This macro requires a plugin ID,
and a mapping of version/aliases assertions to expectations.
generate_resolve_versions_tests!("id", {
"0.4" => "0.4.12",
"0.5.1" => "0.5.1",
"stable" => "1.0.0",
});
To test installing and uninstalling globals, use generate_globals_test!
. This macro requires a
plugin ID, the dependency to install, and an optional environment variable to the globals directory.
// Doesn't support all use cases! If this doesn't work, implement a test case manually.
generate_globals_test!("id", "dependency", "GLOBAL_INSTALL_ROOT");
And lastly, to test shims, use generate_shims_test!
. This requires a plugin ID and a list of shim
file names. This macro generates snapshots using Insta.
generate_shims_test!("id", ["primary", "secondary"]);
Building and publishing
Refer to our official WASM guide for steps on how to build and publish your plugin.
Resources
Some helpful resources for learning about and building plugins.
- Official proto WASM plugins
- Plugin development kit