⏏️

Adding WASM Plugins to Your App

Using Wasmi as a runtime and Zola as an example.

In the past few days I have making a number of small improvements to this website. For example, I used my kashida rust crate, compiled to WASM, to properly justify the Arabic poetry in my previous two articles, with a small JS shim.

When I went to share these articles on social media, I realized I needed OpenGraph thingies to make the link more palatable looking. Adding og:site_name and og:title is simple enough, but what to do about images? For the time being, I decided to use, for all the website, this image of the torn Syrian flag. Not the most relevant photo most of the time, to be honest.

On the Zola forums, I made a proposal for wasm plugins. There is no approval yet, but I decided to try my hand at it anyway. Worst case scenario nothing gets upstreamed and I end up with a nice article out of it.

To be clear, I am writing this article as I am thinking about the problem and experimenting with it. If it feels and reads like an unorganized stream of thoughts, that is because it is.


Passing Data

The first, and most important, problem you face, I think, is the problem of passing arbitrary data (that go beyond a simple integers and floats) between Host and Plugin. The problem of wiring WASM functions to Tera1 functions is something I will worry about later.

The Plugin does not have access to Host memory, so to send data, the Host needs to allocate the bytes in the Plugin's memory. However, the Host has no idea how the Plugin manages its memory, and does not know which sections are used and which are not.

So you need to define a calling convention, an ABI. How about that. Luckily I am not the first person to think of using WASM for plugins.

Frameworks? Components?

There is plenty of plugin frameworks that one can simply just .. use. The one that always bubbles up in search results is Extism. It does handle a lot more than "passing a stream of bytes" like, say, giving shape to these bytes. But it still feels like a lot of ceremony for what is essentially a simple problem.

The Component Model is also something that comes up a lot in research, and it is also, I believe, a lot of ceremony for no real benefit. The plugins don't even need normal WASI. Not to mention that the Component Model is still in proposal status for WebAssembly features. Outside of Rust, Wasmtime, and maybe Mozilla (admittedly some of the biggest players) nobody else seems to care.

Typst

Typst has solved this problem without too much ceremony. It has a very small and minimal plugin protocol. It requires some ceremony from the plugin authors, but it is no more ceremony than loading the Extism SDK, for example.

The main restriction is that all the data is transferred in terms of &[u8], a slice of bytes, and the parsing needs to be done inside the Plugin. The return value is also always a &[u8] that is copied to the framework (which then one uses Typst std library to parse into shape.)

The main trick is this: exported functions take lengths of input (which, remember, are all slices of bytes). The Plugin allocates a giant buffer enough for all the input, and calls the imported function write_args_to_buffer, splits and parses the buffer as needed, then, when done, puts all the results in another buffer, and passes its length and pointer to send_result_to_host, and Bob's your uncle. This is all neatly wrapped in a rust crate, which is really simple to use.

If all else fails, I will just replicate this same protocol and fork wasm-minimal-protocol to accommodate any changes I need.

HarfBuzz and RustyBuzz

I did the WASM plugin implementation for rustybuzz, with lots of help and helpful feedback from everyone. It has been 2 years, though, so my memory is fuzzy.

The main constraint there is that I was implementing an existing API already implemented in HarfBuzz, which uses WAMR as runtime. rustybuzz, by suggestion of laurmaedje, one of the lead Typst developers, uses wasmi, which has the side benefit of being compilable to wasm itself, and copies the much more popular Wasmtime api.

I could not for the life of me figure out how the HarfBuzz API solves this problem. As far as I can tell, it was just some WAMR trick or magic. Does not help that C++ is a black box to me.

The way I solved this in rustybuzz is a bit dirty. To make sure that the memory is unused by the Plugin, I simply .. grew the memory by an additional page (or as many pages as needed), and then copied my data to that location, and gave the pointer back to the Plugin.

It is dirty and cheesy, to be honest. Don't do this. I won't do this. But hey .. it worked!

alloc

The way I am going with this, at least at first, is to require the Plugin to export an alloc function. It should take size as input, and return a pointer to the allocated slice, to which the Host copies what they need, and pass the pointer (and size)2 back to the Plugin, somehow.

A simple example of said alloc function in Rust would be the following:

use std::{
	alloc::{Layout, alloc as allocate, handle_alloc_error},
	ptr::NonNull,
};
pub extern "C" fn alloc(size: u32) -> NonNull<u8> {
	let layout = Layout::from_size_align(size as usize, std::mem::align_of::<u8>()).unwrap();
	let ptr = unsafe { allocate(layout) };
	NonNull::new(ptr).unwrap_or_else(|| handle_alloc_error(layout))
}

Seems simple enough, probably not the most robust implementation, but it is a start. There is no need for free or dealloc, as the Plugin owns the memory now.

My first plugin: Concatenating Strings

With that decision out of the way, hopefully, one can focus on more important matters like actually creating plugins that do something.

Let's start by doing something that is trivial without plugins. Take two strings from Host, concatenate them together and send the result back. This requires no API from the Host, but it helps formulate how passing parameters will look like.

After a bit of experimentation, I ended up with this:

use std::{
	alloc::{Layout, alloc as allocate, handle_alloc_error},
	ptr::NonNull,
};

pub extern "C" fn alloc(size: u32) -> NonNull<u8> {
	let layout = Layout::from_size_align(size as usize, std::mem::align_of::<u8>()).unwrap();
	let ptr = unsafe { allocate(layout) };
	NonNull::new(ptr).unwrap_or_else(|| handle_alloc_error(layout))
}

pub extern "C" fn concat<'a>(
	lhs_size: u32,
	lhs: Option<NonNull<u8>>,
	rhs_size: u32,
	rhs: Option<NonNull<u8>>,
	out: &mut Option<NonNull<u8>>,
) -> u32 {
	let (Some(lhs), Some(rhs)) = (lhs, rhs) else {
		return 0;
	};

	let mut lhs = non_null_to_str(lhs, lhs_size);
	let rhs = non_null_to_str(rhs, rhs_size);

	lhs.push_str(&rhs);
	lhs.shrink_to_fit(); // to get rid of excess capacity;

	let (ptr, length, _) = lhs.into_raw_parts();
	*out = NonNull::new(ptr);

	return length as u32;
}

// The Unsafe functions could be provided by a crate or wrapped in a proc macro.
fn non_null_to_str<'a>(
	ptr: NonNull<u8>,
	size: u32,
) -> String {
	let string = NonNull::slice_from_raw_parts(ptr, size as usize);
	let string = unsafe { string.as_ref() };

	String::from_utf8_lossy(string).into_owned()
}

Note that I have been writing a bunch of Zig for a while, so the code is somewhat Zig-brained. It assumes the Host knows what they are doing and has not passed bad info. Now to compile that into wasm.

The compilation is simple enough:

cargo build -r --target wasm32-unknown-unknown

.. and fish it out of the target directory. There are plenty of tools to interrogate wasm blobs, but I am settling for the stupid approach:

use eyre::Result;
use wasmi::{Engine, Module};

fn main() -> Result<()> {
	let wasm = include_bytes!("../scratch.wasm");
	let engine = Engine::default();
	let module = Module::new(&engine, wasm)?;

	println!("EXPORTS:");
	for export in module.exports().filter(|e| e.ty().func().is_some()) {
		let name = export.name();
		let func = export.ty().func().unwrap();
		let params = func.params();
		let results = func.results();

		println!("\t{name}\tparams: {params:?}\tresults: {results:?}");
	}

	Ok(())
}

.. which prints out .. nothing. Oh I forget to add no_mangle above exported functions, silly me.

#[unsafe(no_mangle)] // so small, so powerful

and it now prints this:

EXPORTS:
	alloc	params: [I32]	results: [I32]
	concat	params: [I32, I32, I32, I32, I32]	results: [I32]

Success!! Now onto using it. This is a bit longwinded.

use eyre::Result;
use wasmi::{Engine, Linker, Module, Store};

fn main() -> Result<()> {
	let wasm = include_bytes!("../concat_plugin.wasm");

	let engine = Engine::default();
	let module = Module::new(&engine, wasm)?;

	let mut store = Store::new(&engine, ());
	let linker = <Linker<()>>::new(&engine);

	let instance = linker.instantiate_and_start(&mut store, &module)?;

	// Get exported functions.
	let alloc = instance.get_typed_func::<u32, u32>(&store, "alloc")?;
	let concat = instance.get_typed_func::<(u32, u32, u32, u32, u32), u32>(&store, "concat")?;

	// Strings to concat
	let lhs = String::from("Hello ");
	let rhs = String::from("Wasm.");

	// allocate them in Plugin Memory
	let lhs_ptr = alloc.call(&mut store, lhs.len() as u32)?;
	let rhs_ptr = alloc.call(&mut store, rhs.len() as u32)?;

	let memory = instance.get_memory(&store, "memory").unwrap();
	{
		let memory_data = memory.data_mut(&mut store);

		memory_data[lhs_ptr as usize..][..lhs.len()].copy_from_slice(lhs.as_bytes());
		memory_data[rhs_ptr as usize..][..rhs.len()].copy_from_slice(rhs.as_bytes());
	}

	// value doesn't matter does it? This is a pointer to a pointer.
	let out_ptr = alloc.call(&mut store, size_of::<u32>() as u32)?;

	// do the thing
	let result_len = concat.call(
		&mut store,
		(lhs.len() as u32, lhs_ptr, rhs.len() as u32, rhs_ptr, out_ptr),
	)?;

	let out_ptr =
		memory.data(&store)[out_ptr as usize..][..size_of::<u32>()].try_into().unwrap();
	let out_ptr = u32::from_le_bytes(out_ptr) as usize;

	let output = &memory.data(&store)[out_ptr..][..result_len as usize];
	let output = String::from_utf8_lossy(output);

	println!("{output}"); // Hello Wasm.

	Ok(())
}

IT PRINTS!

Obviously a lot of this is just insane boilerplate, but onwards I persist. It outputs correctly and that is all that matters.

The casting between u32 and usize is because the Plugin works in a 32 bit environment, so its pointers are u32. But indices into things in Rust is usize, hence the small friction.

One thing to note here is that there is nothing from Host side that prevents the function to return both the pointer and the length. WebAssembly, after all, allows multiple returns. However, as far as I can tell, there is no easy way in Rust to compile a function with multiple returns. (And the fact is, even in languages with multiple returns, like Odin, it compiles them as out parameters like I did here.)

However, the Plugin could return either a pointer to a struct that has a length and a pointer (which is the sane option, to be honest), or return a u64 that is a u32 size and u32 pointer address stuck side by side.


Second Plugin. Create an Image and Save it

With no WASI, I hear you ask, how will you save an image? Simple: have the Host export a function (save_file) where it takes a byte stream and returns a file path, which the Plugin can pass back to the Host to embed in the HTML template. This was all motivated by OpenGraph images, wasn't it?

Now, laying out what specifically happens in that image is beyond the scope of this particular section. Instead, we can have the exported function, called create_image or whatever, take a color, and it creates a small 64x64 image of that color, uses save_file to save it, and then create_image returns the path. To make the API slightly simpler, I will pass strings and paths around as null-terminated strings, so just a pointer.

Host Side

Here is the save_file function that Plugin will be able to import. Note that Caller is an implementation detail for wasmi.

fn save_file(
	caller: Caller<()>,
	name: u32,
	file_ptr: u32,
	file_size: u32,
) -> u32 {
	save_file_impl(caller, name, file_ptr, file_size).map(|i| i.into()).unwrap_or_default()
}

fn save_file_impl(
	mut caller: Caller<()>,
	// null terminated
	name: u32,
	file_ptr: u32,
	file_size: u32,
) -> Option<NonZero<u32>> {
	// Get the necessary exports out
	let memory = caller.get_export("memory")?.into_memory()?;
	let alloc = caller.get_export("alloc")?.into_func()?;

	let data = memory.data(&caller);

	// name can be whatever. It can even be randomly genrated because we are not in WASM
	let dir_path = PathBuf::from("plugin_images");

	// not very robust error handling
	_ = std::fs::create_dir(&dir_path);
	let name = CStr::from_bytes_until_nul(&data[name as usize..]).ok()?.to_str().ok()?;

	// straight forward writing the file
	let file_path = dir_path.join(name);
	let file_data = &data[file_ptr as usize..][..file_size as usize];

	let mut file = std::fs::File::create(&file_path).ok()?;
	let _written = file.write(file_data).ok()?;

	// now that's done, return a the file path to the caller.
	// trading the API simplicity of null terminated strings with code complexity
	let file_path = file_path.as_os_str().as_encoded_bytes();
	let file_path = CString::new(file_path).ok()?;
	let file_path = file_path.as_bytes_with_nul();

	let mut func_outputs: Vec<Val> = Vec::new();
	alloc.call(&mut caller, &[Val::I32(file_path.len() as i32)], &mut func_outputs).ok()?;

	let Some(Val::I32(return_pointer)) = func_outputs.first() else {
		return None;
	};

	let return_pointer = *return_pointer as u32;
	// This silly dance to avoid wriitng into address 0
	let return_value = NonZeroU32::new(return_pointer)?;

	// write file_path into Plugin memory
	let data = memory.data_mut(&mut caller);
	data[return_pointer as usize..][..file_path.len()].copy_from_slice(file_path);

	Some(return_value)
}

And, back in main, add it to imports by calling func_wrap on linker:

let mut linker = <Linker<()>>::new(&engine);
linker.func_wrap("scratch_env", "save_file", save_file)?;

Note that scratch_env here can be anything the ABI author decides. LLVM puts env as default value for imports, if I remember correctly. This is something the Plugin author needs to be aware of.

Plugin side

To import save_file, which the Plugin author knows is there because of the correct and up-to-date documentation, they do the following incantation.

#[link(wasm_import_module = "scratch_env")] // this name must be the same.
unsafe extern "C" {
	fn save_file(
		name: Option<NonNull<u8>>,
		file_ptr: Option<NonNull<u8>>,
		file_size: u32,
	) -> Option<NonNull<u8>>;
}

and the function is available to use from within your Plugin, gated with unsafe, of course. You can never have enough places where you write unsafe in Rust.

create_image itself is straightforward. It even returns the same pointer given to it by save_file.

#[unsafe(no_mangle)] // don't forget!!
pub extern "C" fn create_image(color: u32) -> Option<NonNull<u8>> {
	// first things first
	let name = format!("{color:X}.png");
	let name = CString::from_str(&name).ok()?;
	let name = NonNull::new(name.into_raw()).map(|p| p.cast());

	// Rgba and RgbaImage from the `image` crate
	let color: [u8; 4] = color.to_le_bytes();
	let color = Rgba::from(color);

	let buffer = Vec::new();
	let mut buffer = Cursor::new(buffer);

	let image = RgbaImage::from_pixel(64, 64, color);
	image.write_to(&mut buffer, image::ImageFormat::Png).ok()?;

	let mut buffer = buffer.into_inner();
	let ptr = buffer.as_mut_ptr();
	let ptr = NonNull::new(ptr);

	unsafe { save_file(name, ptr, buffer.len() as u32) }
}

Host side again

All that's left now is defining the new exported function and calling it.

fn main() -> Result<()> {
	let wasm = include_bytes!("../create_image.wasm");

	let engine = Engine::default();
	let module = Module::new(&engine, wasm)?;

	let mut store = Store::new(&engine, ());
	let mut linker = <Linker<()>>::new(&engine);

	linker.func_wrap("scratch_env", "save_file", save_file)?;

	let instance = linker.instantiate_and_start(&mut store, &module)?;

	let memory = instance.get_memory(&store, "memory").unwrap();

	// Get exported functions.
	let _alloc = instance.get_typed_func::<u32, u32>(&store, "alloc")?;
	let create_image = instance.get_typed_func::<u32, u32>(&store, "create_image")?;

	let image_path = create_image.call(&mut store, 0xFF0000FF)?; // red
	let image_path = CStr::from_bytes_until_nul(&memory.data(&store)[image_path as usize..])?;

	println!("image saved at {}", image_path.to_string_lossy());

	Ok(())
}

And I run this, and it saves this glorious image right where I asked it to:

red square

Yes it is a red square. That is what it is asked to do. However .. main above prints the following:

image saved at

Debugging

No path given! create_image actually returned (from its point of view, at least) a null pointer. Obviously the image was created and saved, so the error must be within save_file. Poking at it a bit, with good old debug printing, I found the error is in this part in save_file:

let mut func_outputs: Vec<Val> = Vec::new();
alloc.call(&mut caller, &[Val::I32(file_path.len() as i32)], &mut func_outputs).ok()?;

Apparently that's not the way this should be called, so I am confused. I thought it would fill up the func_outputs buffer, but instead it is giving me an encountered an incorrect number of results error. Hm. Time to read the documentation:

    /// Calls the Wasm or host function with the given inputs.
    ///
    /// The result is written back into the `outputs` buffer.
    ///
    /// # Errors
    ///
    /// - If the function returned a [`Error`].
    /// - If the types of the `inputs` do not match the expected types for the
    ///   function signature of `self`.
    /// - If the number of input values does not match the expected number of
    ///   inputs required by the function signature of `self`.
    /// - If the number of output values does not match the expected number of
    ///   outputs required by the function signature of `self`.

So while that does say the &mut [Val] outputs parameter needs to have the correct number, it does not actually say how to use it. The only version of this specific method being called in wasmi codebase is calling it is a &mut [] which is not very helpful.

Anyhow, changing the first of the above to lines to

let mut func_outputs = vec![Val::I32(0)];
alloc.call(&mut caller, &[Val::I32(file_path.len() as i32)], &mut func_outputs).ok()?;

and running the scratch repo again (this time changing the color to blue) gives a beautiful blue square similar to the previous red square and the following message:

image saved at plugin_images/FFFF0000.png

blue square

Maybe I should've used to_be_bytes instead of to_le_bytes to make it make more sense, but no matter. The important thing is that it works. Also, perhaps I could have arranged save_file better so it cannot fail after writing the file.


Tera

By now, a clear ABI has been established. Data and functions are successfully shepherded back and forth between Host and Plugin. Next on the menu is tying it to Tera.1 I have almost no idea how to do that.

Browsing the Zola codebase, the main gateway seems to be a method called register_function. This method takes something which implements a trait called, appropriately enough, Function, which requires objects to implement a method with another appropriate name: call, that takes its inputs in the form of a HashMap<String, Value>. Function parameters in Tera must have a name, while in Wasm land, they don't. First point of friction right there, and no I am not touching the component model.

Value in here is a little more interesting. It is actually exported all the way over from serde_json, for some reason, and it is therefore a viable JSON Value. Unsure yet how to bridge this back and forth, but only Plugin exported functions that are not alloc need to fit that mould.

It is more interesting right now to consider how the object that implements this trait looks like. It needs to have some sort of reference to the Module, (or perhaps an instantiated Module in a LazyLock?) which it can call functions from. Each instance of said object only needs concern itself with one, and only one, exported function.

The second immediate point of friction is how to map the types from one function model to the other. Both models allow integers, floats, and strings, pretty easily, but how to actually know which parameter is which type, considering they all look the same (i32) when inspected in Wasm? The simplest approach to this is Typst's: all parameters are all strings byte slices all the time and if the Host needs to pass numbers they pass them as strings byte slices and let the Plugin worry about parsing them.

Return types are a bit simpler: they can always be strings byte slices. This plugin system is going to produce text, so no need to overthink this too much. But knowing success from failure is a bit trickier: so far, I signaled failure by returning 0, and success by returning any other number (which is usually a valid pointer). There is no space for error messages should that be needed. In which case, I could copy Typst's approach here again, and provide a send_result_to_host function that can either contain an arbitrary slice of bytes or an error message.

This, coming to think of it, sounds a lot like save_file already. Maybe I could have the Host define a function to report error strings that is checked when return value is 0? Tricky.

Anyway, enough of this stream of thought. The crowds want to see some code.

Say Hello randomly

I have never used Tera before, but it seems straightforward enough. First let me define the terribly inefficient function I would like to call from templates:

fn get_random_name() -> String {
	let mut names = HashSet::new();
	names.insert("Abdul-Karim");
	names.insert("Faisal");
	names.insert("Juman");
	names.into_iter().next().unwrap().to_owned()
}

Who said there is no randomness in the standard library? Implementing the tera::Function trait is similarly straightforward.

struct GetRandom;
impl Function for GetRandom {
	fn call(
		&self,
		_: &HashMap<String, tera::Value>,
	) -> tera::Result<tera::Value> {
		let ret = get_random_name();
		Ok(tera::Value::String(ret))
	}
}

There is no use for any arguments being passed, so args is discarded. Note that there is no way to pass positional arguments, here, which does make the WASM-Tera interop slightly more difficult. Maybe if instead of HashMap there was some sort of ordered map instead, where the user can simply iterate over the hashmap to get arguments in order, regardless of the key value? But I digress. Back to main.

fn main() -> Result<()> {
	let mut tera = Tera::default();

	// register our function
	tera.register_function("random_name", GetRandom);

	// call our function in the `hello` template
	tera.add_raw_template("hello", concat!("Hello {{  random_name() }}"))?;

	let context = tera::Context::new();

	// render `hello`
	let rendered = tera.render("hello", &context)?;

	println!("{rendered}");
	Ok(())
}

And .. that is it. Run it and get Hello, Abdul-Karim!, or any of the other names, right into your terminal. Coming to think of it, it is kinda funny that this is not the most convoluted Hello World in this article.

Well, the Tera section was easier and shorter than I expected. Time to tie the knot, bridge the gap, mix metaphors.


Tying the Two Threads

The tricky part is to know which parts of the Wasm Rube Goldberg machine to store references to. Conferring with the ghost trapped in a jar, which I am sure has done ample research on the problem, we came up with this struct:

struct WasmFunction {
	store_memory: Arc<Mutex<(Store<()>, Memory)>>,
	func: Func,
}

The Arc<Mutex<(Store<()>, Memory)>> type salad speaks for itself. Func is a "type-erased" version of the instantiated module's functions. As a matter of fact, it is the same type that save_file, way above, used to call alloc, which would explain the different calling syntax between it and ones in main (which operate on TypedFunc<Params, Results>).

This also has the potential drawback of eagerly compiling all plugins, instead of only lazily instantiating plugins the user uses.

Implementing tera::Function needs deciding on one element of the API. Tera functions are named, with no positional arguments and no guaranteed order. Wasm functions are only positional, and the arguments have no easy names to extract. The gap could be bridged by requiring Plugins to provide some sort of manifest.json file that included exported function names and their parameters names.

The simplest way to do is simple generate names for the Wasm function's parameters, based on their position. So the first parameter would be p0 (must start with a letter), second is p1, and so on.

Regarding the function's types, I will kick this can down the road. I know create_image specifically takes a u32 integer, so I will just make that assumption. If the API discussion decides strings byte slices all the time everywhere is the way to go, then the logic here will be different. For example, creating a slice out of every pair of parameters or whatever, or just provide a manifest.

Without further ado, here is the implementation with comments.

impl tera::Function for WasmFunction {
	fn call(
		&self,
		args: &HashMap<String, Value>,
	) -> tera::Result<Value> {
		let mut sm = self.store_memory.lock().unwrap();

		// unused here, but you can get number and types of params from it
		let _func_type = self.func.ty(sm.0.as_context());

		// hardcoded for `create_image`. Ideally this would be different
		let Some(Value::Number(color)) = args.get("p0") else {
			return Err(tera::Error::msg("Missing parameter"));
		};
		let color = color.as_u64().unwrap() as u32;

		// the code from here is similar to `save_file`
		let mut func_outputs = vec![wasmi::Val::I32(0)];
		self.func
			.call(
				sm.0.as_context_mut(),
				&[wasmi::Val::I32(color as i32)],
				&mut func_outputs,
			)
			.unwrap();

		let Some(wasmi::Val::I32(image_path)) = func_outputs.first() else {
			unreachable!("lots of handwaiving")
		};

		// and from here is similar to what was in `main`
		let image_path = *image_path as u32;
		let image_path = CStr::from_bytes_until_nul(
			&sm.1.data(sm.0.as_context())[image_path as usize..],
		)
		.unwrap()
		.to_string_lossy();

		Ok(image_path.into())
	}
}

Note that this is hardcoded for the same create_image module I created earlier, no recompilation necessary. A correct implementation would be more .. I dunno .. correct. This is but a proof of concept.

Adding Tera things to main, we now have this monstrosity:

fn main() -> Result<()> {
	let wasm = include_bytes!("../create_image.wasm");

	let engine = Engine::default();
	let module = Module::new(&engine, wasm)?;

	let mut store = Store::new(&engine, ());
	let mut linker = <Linker<()>>::new(&engine);

	linker.func_wrap("scratch_env", "save_file", save_file::save_file)?;

	let instance = linker.instantiate_and_start(&mut store, &module)?;

	let memory = instance.get_memory(&store, "memory").unwrap();

	// Get exported functions.
	let _alloc = instance.get_typed_func::<u32, u32>(&store, "alloc")?;
	let create_image = instance.get_typed_func::<u32, u32>(&store, "create_image")?;

	let state = Arc::new(Mutex::new((store, memory)));
	let tera_func = WasmFunction { store_memory: state, func: create_image.func().clone() };

	// from here on we do Tera things
	let mut tera = Tera::default();

	// register our function
	tera.register_function("create_image", tera_func);

	// call our function in the `hello` template
	// 4278255360 is 0xFF00FF00 . No hex, apparently.
	tera.add_raw_template("hello", concat!("Hello {{ create_image(p0=4278255360) }}"))?;

	let context = tera::Context::new();

	// render `hello`
	let rendered = tera.render("hello", &context)?;

	println!("{rendered}");

	Ok(())
}

And this prints the follwing message and saves the following glorious green square:

Hello plugin_images/FF00FF00.png

green square

This has been an overwhelming success.


What is Left

This article has gone long enough. Now all the hard work is done, all that remains is small details like the following:

  1. How to organize the internal data structures.
  2. Which layer of Zola to expose these functions.
  3. Where to place plugin files in the Zola directory.
  4. The full ABI: What types to force Plugin writers to use.
  5. The full API: What functions are available to Plugin writers.
  6. How to name the functions on Tera's end so they do not overlap with other functions.
  7. Whether Plugin authors should be left to their own devices or given a small crate to build on.
  8. Whether this is wanted or not, and whether Lua or Rhai would be more preferable.

All small details, insignificant in the grand scheme of things. I expect this feature to get approved and built and upstreamed in no time.

I love Zola: it enabled me to start this site and get my voice out into the world. This makes it even better.

Until later.


  1. Zola's templating engine. ↩2

  2. Note that when passing strings back and forth, one often could just use C-strings and not need to pass a length. That is not possible for arbitrary byte streams like images, but I will cross that bridge when I come to it.