Back in 2020, before the pandemic really hit, I started working on two projects built on top of Cloudflare Workers. One of those projects was the Mineteria Store, which jammed together server-side React running on top of an environment that server-side React didn't exactly run on at the time mixed with traditional client-side rendering, and a service to render Minecraft avatars called Crafthead, now maintained by Nodecraft. (Funny enough, the Mineteria Store is, to my knowledge, the only deployment of Elixir that was remotely close to the Minecraft server scene. What a time that was.)
But I have a lot of holiday downtime and I wasn't exactly doing much of anything. Plus, at Ramp, I am now part of the same team that works on our authorizer service (something legendary in its own right: written by Pablo Meier, who may not be with Ramp any more, but it is the subject of this classic blog post, and the authorizer service continues to faithfully handle millions of authorizations per day with the power of Elixir).
So let's do the natural thing and rewrite Crafthead in Elixir...
...but not quite! #
Crafthead is actually divided into two components:
- The actual application, written in fairly standard TypeScript aside from some extensions to the runtime done by Cloudflare. It is basically a Service Worker running in a non-browser environment.
- A Rust library responsible for rendering images. There is a shim API that handles the JS-to-Rust translation, and this is ultimately compiled into WebAssembly.
The main reason for this was simple: I knew more TypeScript/JavaScript than I did Rust, and I wasn't excited about the prospect of writing a Worker entirely in Rust. (Also worth noting that you couldn't have even written a Worker entirely in a language that compiled to WebAssembly until Cloudflare added support for Liftoff tiering, since on a cold boot, the WebAssembly would have to go through TurboFan compilation, a compiler not known for being fast.)
Along the way, Crafthead makes use of Cloudflare Workers KV as its primary datastore. This is essentially a Memcached-like API with some extra bells and whistles we won't care about. We can substitute it for Memcached, Redis, a relational database, or an ETS table.
So what's our strategy? #
It's not really hard to deduce a strategy from here:
- The actual application, written in Elixir, and built on top of the Phoenix Framework. Phoenix provides essentially everything including the kitchen sink.
- We will need a cache: we'll use Nebulex to handle this.
- Req reminds me a lot of
python-requests
and is easy to add (it's built on top of Finch and Mint), so we will use this to call the Mojang API. - A Rust library responsible for rendering images, using Rustler to handle the Erlang-to-Rust translation, and compiled as a NIF.
Fun! We have not even written a single line of code and we are already playing with fire by introducing NIFs into the mix. We will have to take care of that later, but first we need to get a Phoenix project going:
mix phx.new crafthead --no-html --no-assets --no-ecto --no-mailer
This creates the crafthead
project with an extremely barebones configuration. The only HTML files we'll serve are static assets anyway, and Ecto will not be necessary at the moment, since caching can be done locally using an ETS table.
Now we can launch the project and start writing some code! First, let's install Nebulex:
defp deps do
[
{:nebulex, "~> 2.5"},
{:decorator, "~> 1.4"},
# ...
]
end
Run mix deps.get
, mix nbx.gen.cache -c Crafthead.Cache
, and then add Crafthead.Cache
as instructed, and then we have our cache!
Writing a Mojang API client #
Right! Let's go ahead and build a minimal Mojang API client. The API is pretty well-documented but we only need a handful of things:
- Map a username to a UUID
- Obtain the profile and skin of a player by their UUID
That's where Req comes into the picture. Let's install it now:
defp deps do
[
{:req, "~> 0.4.0"},
# ...
]
end
Run mix deps.get
and now we have a richly-featured HTTP client at our fingertips. Now we can write our Mojang API client:
defmodule Crafthead.Clients.Mojang do
@username_to_uuid_mapping_url "https://api.mojang.com/users/profiles/minecraft/"
@uuid_to_profile_url "https://sessionserver.mojang.com/session/minecraft/profile/"
def username_to_uuid(username) do
url = @username_to_uuid_mapping_url <> username <> "?unsigned=false"
with {:ok, resp} <- Req.get(url) do
case resp.status do
200 -> {:ok, resp.body["id"]}
404 -> {:error, :not_found}
429 -> {:error, :too_many_requests}
500 -> {:error, :internal_server_error}
503 -> {:error, :service_unavailable}
_ -> {:error, :unknown}
end
end
end
def uuid_to_profile(uuid) do
url = @uuid_to_profile_url <> uuid
with {:ok, resp} <- Req.get(url) do
case resp.status do
200 -> {:ok, resp.body}
204 -> {:error, :not_found}
404 -> {:error, :not_found}
429 -> {:error, :too_many_requests}
500 -> {:error, :internal_server_error}
503 -> {:error, :service_unavailable}
_ -> {:error, :unknown}
end
end
end
end
There is a little bit more to it than this, because the actual skin is in the properties
object and requires some
unwrapping, so let's build that out:
defmodule Crafthead.Profile.Minecraft do
@doc """
Implements a raw representation of a Minecraft profile, from the Mojang API. Includes a convenience function to
obtain the skin information for a player.
"""
@permitted_texture_types %{"SKIN" => :skin, "CAPE" => :cape}
defstruct [:id, :name, :properties]
def get_skin_info(profile) do
profile
|> find_texture_property()
|> decode_texture_property!()
|> get_texture_urls()
end
defp find_texture_property(profile) do
Enum.find(profile.properties, fn property ->
property["name"] == "textures"
end)
end
defp decode_texture_property!(property) when is_map(property) do
property["value"]
|> Base.decode64!()
|> Jason.decode!()
end
defp decode_texture_property!(property) when is_nil(property), do: nil
defp get_texture_urls(textures_property) when is_map(textures_property) do
@permitted_texture_types
|> Enum.filter(fn {texture_type, _} ->
Map.has_key?(textures_property["textures"], texture_type)
end)
|> Enum.map(fn {texture_type, texture_type_atom} ->
{texture_type_atom, textures_property["textures"][texture_type]["url"]}
end)
|> Map.new()
end
defp get_texture_urls(textures_property) when is_nil(textures_property), do: nil
end
We elide a bunch of error handling here since all profiles come from the same trusted source, namely the Mojang servers.
While we are at it, it's also a good idea to write a unit test for this class, since it does something non-trivial:
defmodule Crafthead.Profile.MinecraftTest do
use ExUnit.Case
describe "get_skin_info/1" do
test "returns skin information" do
profile = %Crafthead.Profile.Minecraft{
:id => "652a2bc4e8cd405db7b698156ee2dc09",
:name => "tuxed",
:properties => [
%{
"name" => "textures",
"value" => "ewogICJ0aW1lc3RhbXAiIDogMTcwMzgyNzIzMTg0OCwKICAicHJvZmlsZUlkIiA6ICI2NTJhMmJjNGU4Y2Q0MDVkYjdiNjk4MTU2ZWUyZGMwOSIsCiAgInByb2ZpbGVOYW1lIiA6ICJ0dXhlZCIsCiAgInRleHR1cmVzIiA6IHsKICAgICJTS0lOIiA6IHsKICAgICAgInVybCIgOiAiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS9kOWM1MjcwMzUwOWM3MDcwNDczMGIyZDg4MmIzNjdjMTEyMWM2NGYzZDZmNmE5ZDEyNmU0N2IxZmU2NTY4MGI5IgogICAgfQogIH0KfQ=="
}
]
}
expected_result = %{
skin: "http://textures.minecraft.net/texture/d9c52703509c70704730b2d882b367c1121c64f3d6f6a9d126e47b1fe65680b9"
}
assert Crafthead.Profile.Minecraft.get_skin_info(profile) == expected_result
end
test "returns cape information" do
profile = %Crafthead.Profile.Minecraft{
:id => "1ccef50bf6ae4542b1a9d434384a5b25",
:name => "Jeff",
:properties => [
%{
"name" => "textures",
"value" => "ewogICJ0aW1lc3RhbXAiIDogMTcwMzgyOTE5NjM0NCwKICAicHJvZmlsZUlkIiA6ICIxY2NlZjUwYmY2YWU0NTQyYjFhOWQ0MzQzODRhNWIyNSIsCiAgInByb2ZpbGVOYW1lIiA6ICJKZWZmIiwKICAidGV4dHVyZXMiIDogewogICAgIlNLSU4iIDogewogICAgICAidXJsIiA6ICJodHRwOi8vdGV4dHVyZXMubWluZWNyYWZ0Lm5ldC90ZXh0dXJlL2Y3ZjdhNWFmYmY5NDZkYWUxOWZhMzhjMjIxZTMxMDM0NTE5YjE5OWViMWZkZTkwZTI0OTUxNzJlOWQ3ZDZhIgogICAgfSwKICAgICJDQVBFIiA6IHsKICAgICAgInVybCIgOiAiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS85NTNjYWM4Yjc3OWZlNDEzODNlNjc1ZWUyYjg2MDcxYTcxNjU4ZjIxODBmNTZmYmNlOGFhMzE1ZWE3MGUyZWQ2IgogICAgfQogIH0KfQ=="
}
]
}
expected_result = %{
skin: "http://textures.minecraft.net/texture/f7f7a5afbf946dae19fa38c221e31034519b199eb1fde90e2495172e9d7d6a",
cape: "http://textures.minecraft.net/texture/953cac8b779fe41383e675ee2b86071a71658f2180f56fbce8aa315ea70e2ed6",
}
assert Crafthead.Profile.Minecraft.get_skin_info(profile) == expected_result
end
end
end
Building our first endpoints #
At this point, we are able to fetch a skin from the Mojang API, which is awesome. We can now create our first endpoints. Specifically, we will implement the /profile/<username|UUID>
and /skin/<username|UUID>
endpoints.
First, we need to be able to tell if we are working with a username or a UUID. It will also normalize the entity provided (such as lowercasing the username or removing dashes in UUIDs) so that equivalent entities will map to the same cached value, which will be important later on. The function is simple, so a doctest can suffice to document and serve as the entire test suite:
defmodule Crafthead.Util.Request do
@mojang_uuid_regex ~r/^[0-9a-f]{32}$/
@regular_uuid_regex ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
@doc ~S"""
Given the Minecraft profile `entity`, determine if it refers to a UUID or a username.
## Examples
iex> Crafthead.Util.Request.what_entity("1ccef50bf6ae4542b1a9d434384a5b25")
{:uuid, "1ccef50bf6ae4542b1a9d434384a5b25"}
iex> Crafthead.Util.Request.what_entity("4525ca19-5825-4774-bb90-a004de1460c3")
{:uuid, "4525ca1958254774bb90a004de1460c3"}
iex> Crafthead.Util.Request.what_entity("tuxed")
{:username, "tuxed"}
iex> Crafthead.Util.Request.what_entity("LadyAgnes")
{:username, "ladyagnes"}
"""
def what_entity(entity) do
cond do
Regex.match?(@mojang_uuid_regex, entity) -> {:uuid, entity}
Regex.match?(@regular_uuid_regex, entity) -> {:uuid, String.replace(entity, "-", "")}
true -> {:username, String.downcase(entity)}
end
end
end
Now we can implement the first controller to fetch profiles:
defmodule CraftheadWeb.ProfileController do
alias Crafthead.Clients.Mojang
alias Crafthead.Util.Request
use CraftheadWeb, :controller
def show(conn, %{"entity" => entity}) do
result = entity
|> Request.what_entity()
|> get_profile()
case result do
{:ok, profile} ->
conn
|> put_status(:ok)
|> put_resp_header("cache-control", "public, max-age=86400")
|> json(profile)
{:error, :not_found} ->
conn
|> put_status(:not_found)
|> put_view(json: CraftheadWeb.ErrorJSON)
|> render(:"404")
{:error, _} ->
conn
|> put_status(:internal_server_error)
|> put_view(json: CraftheadWeb.ErrorJSON)
|> render(:"500")
end
end
defp get_profile({:uuid, entity}) do
Mojang.uuid_to_profile(entity)
end
defp get_profile({:username, entity}) do
with {:ok, uuid} <- Mojang.username_to_uuid(entity) do
get_profile({:uuid, uuid})
end
end
end
Add this to the router:
scope "/", CraftheadWeb do
pipe_through :api
get "/profile/:entity", ProfileController, :show
get "/ping", PingController, :show
end
Now if you go to http://localhost:4000/profile/tuxed
, you'll see my Minecraft profile. Our first endpoint is implemented! We should definitely stop and clean this up a bit, though - we're going to carry a lot of duplicated code around if we don't define a fallback controller. I will elide that, but assume it exists for any other controllers we implement.
Next up, we're going to implement endpoints for fetching skins. This is pretty easy:
defmodule CraftheadWeb.SkinController do
alias Crafthead.Clients.Mojang
alias Crafthead.Profile.Minecraft
alias Crafthead.Util.Request
use CraftheadWeb, :controller
action_fallback CraftheadWeb.FallbackControlle
def show(conn, %{"entity" => raw_entity}) do
entity = raw_entity |> Request.what_entity()
with {:ok, profile} <- get_profile(entity),
texture_info <- Minecraft.get_skin_info(profile),
{:ok, skin} <- fetch_skin(texture_info) do
conn
|> put_resp_header("cache-control", "public, max-age=86400")
|> put_resp_header("content-type", "image/png")
|> send_resp(200, skin)
end
end
defp get_profile({:uuid, entity}) do
Mojang.uuid_to_profile(entity)
end
defp get_profile({:username, entity}) do
with {:ok, uuid} <- Mojang.username_to_uuid(entity) do
get_profile({:uuid, uuid})
end
end
defp fetch_skin(%{:skin => skin_url}) do
with {:ok, resp} <- Req.get(skin_url) do
case resp.status do
200 -> {:ok, resp.body}
404 -> {:error, :not_found}
429 -> {:error, :too_many_requests}
500 -> {:error, :internal_server_error}
503 -> {:error, :service_unavailable}
_ -> {:error, :unknown}
end
end
end
end
This concludes a very basic implementation of an skin and profile-fetching service for Minecraft written in Elixir, but we are far from done yet. We need to add in a few more creature comforts.
Caching and rate-limiting #
Since the Mojang API has rate limits, we can't simply request data from it every time we need it, so we are expected to cache the data. Luckily, that's why we pulled in Nebulex earlier. Let's modify our Mojang API client:
defmodule Crafthead.Clients.Mojang do
alias Crafthead.Cache
use Nebulex.Caching
@username_ttl :timer.hours(1)
@profile_ttl :timer.hours(24)
# ...same code...
@decorate cacheable(cache: Cache, key: {:username_to_uuid, username}, opts: [ttl: @username_ttl])
def username_to_uuid(username) do
# ...same code...
end
@decorate cacheable(cache: Cache, key: {:uuid_to_profile, uuid}, opts: [ttl: @profile_ttl])
def uuid_to_profile(uuid) do
# ...same code...
end
end
Now we have a cache around the Mojang API - one hour for usernames, and one day for profiles. We can also easily ensure skin textures are also cached so we don't need to fetch them from Mojang every time too.
Time to bring in the Rustler! #
You have sat here for a while, now it's time for the fun part: adding a NIF to this project. Let's install Rustler first:
defp deps do
[
{:rustler, "~> 0.30.0", runtime: false}
# ...
]
end
Run mix deps.get
and then create the Rustler module:
# mix rustler.new
This is the name of the Elixir module the NIF module will be registered to.
Module name > Crafthead.Renderer
This is the name used for the generated Rust crate. The default is most likely fine.
Library name (crafthead_renderer) >
* creating native/crafthead_renderer/.cargo/config.toml
* creating native/crafthead_renderer/README.md
* creating native/crafthead_renderer/Cargo.toml
* creating native/crafthead_renderer/src/lib.rs
* creating native/crafthead_renderer/.gitignore
Ready to go! See [...]/crafthead/native/crafthead_renderer/README.md for further instructions.
The first thing I did was copy the dependencies from the original Crafthead project into the new project's Cargo.toml
:
[package]
name = "crafthead_renderer"
version = "0.1.0"
authors = []
edition = "2021"
[lib]
name = "crafthead_renderer"
path = "src/lib.rs"
crate-type = ["cdylib"]
[dependencies]
rustler = "0.30.0"
imageproc = { version = "0.22.0", default-features = false }
[dependencies.image]
# Make `image` more lightweight. We don't need every image format under the sun,
# just PNG.
version = "0.23.14"
default-features = false
features = ["png"]
Following that, I copied all the remaining files into the new Rust project as well, and made a copy of the old src/lib.rs
for reference. Now we will set about moving the API from Wasm to Rustler. We'll start with simple cases and progressively move upwards. First, consider this function which serves as the gateway into the WebAssembly version:
#[wasm_bindgen]
pub fn get_rendered_image(
skin_image: Uint8Array,
size: u32,
what: String,
armored: bool,
slim: bool,
) -> Result<Uint8Array, JsValue> {
Consider an idiomatic Elixir interface:
@type render_type() :: :avatar | :helm | :cube | :body | :bust | :cape
@type skin_type() :: :classic | :slim
@spec render_image(binary(), render_type(), [armored: boolean(), skin: skin_type(), size: integer()]) :: binary()
def render_image(image, what, options \\ []) do
# ...
end
We're not going to be using this interface exactly, but it helped give me a good mental model for converting the existing code into Rustler. I wound up with this:
mod skin;
mod utils;
use std::io::Cursor;
use image::DynamicImage;
use rustler::{Binary, Env, Error, NifResult, NifStruct, NifUnitEnum, OwnedBinary};
use skin::{BodyPart, Layer, MinecraftSkin, SkinModel};
mod atoms {
rustler::atoms! {
// Error types.
invalid_image,
unable_to_render
}
}
#[derive(Copy, Clone, NifUnitEnum)]
enum RenderType {
Avatar,
Helm,
Cube,
Body,
Bust,
Cape,
}
#[derive(NifStruct)]
#[module = "Crafthead.Renderer.RenderOptions"]
struct RenderOptions {
pub render_type: RenderType,
pub size: u32,
pub armored: bool,
pub model: SkinModel,
}
impl RenderType {
// ...elided...
}
#[rustler::nif]
fn render_image<'a>(
env: Env<'a>,
skin_image: Binary<'a>,
options: RenderOptions,
) -> NifResult<Binary<'a>> {
let image_copy = skin_image.as_slice();
let skin_result = image::load_from_memory_with_format(&image_copy, image::ImageFormat::Png);
match skin_result {
Ok(skin_img) => {
let skin = MinecraftSkin::new(skin_img);
let rendered = options.render_type.render(&skin, &options);
// We can't predict the size of the output, so we can't just write directly into an OwnedBinary.
let mut result = Vec::with_capacity(1024);
let mut cursor = Cursor::new(&mut result);
return match rendered.write_to(&mut cursor, image::ImageFormat::Png) {
Ok(()) => {
let mut binary = OwnedBinary::new(result.len()).unwrap();
binary.as_mut_slice().copy_from_slice(&result[..]);
Ok(Binary::from_owned(binary, env))
}
Err(_err) => Err(Error::Term(Box::new(atoms::unable_to_render()))),
};
}
Err(_err) => Err(Error::Term(Box::new(atoms::invalid_image()))),
}
}
rustler::init!("Elixir.Crafthead.Renderer", [render_image]);
The upshot is that we do not need to copy the original skin twice, unlike the Wasm version. Because of the neater FFI provided by Rustler, the render_image
function reads a lot more naturally. The only weird parts are that we need to copy the output twice and there is a lifetime now attached to the render_image
call bound to the NIF call, but it's not really a big deal.
Now to load the NIF and expose the renderer:
defmodule Crafthead.Renderer.RenderOptions do
defstruct render_type: nil,
size: nil,
armored: false,
model: nil
end
defmodule Crafthead.Renderer do
use Rustler, otp_app: :crafthead, crate: "crafthead_renderer"
def render_image(_skin_image, _options), do: error()
defp error, do: :erlang.nif_error(:nif_not_loaded)
end
It works!
iex(5)> render = Crafthead.Renderer.render_image(alex, %Crafthead.Renderer.RenderOptions{render_type: :avatar, size: 300, model: :slim})
<<137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 1, 44, 0,
0, 1, 44, 8, 6, 0, 0, 0, 121, 125, 142, 117, 0, 0, 14, 15, 73, 68, 65, 84,
120, 156, 237, 212, 177, 206, 118, 233, 28, ...>>
A very dirty NIF #
Here is the problem with this NIF: although it is quite fast, it's not fast enough to be kept on the same scheduler as everything else. Quoting the Erlang documentation:
As mentioned in the warning text at the beginning of this manual page, it is of vital importance that a native function returns relatively fast. It is difficult to give an exact maximum amount of time that a native function is allowed to work, but usually a well-behaving native function is to return to its caller within 1 millisecond.
The easiest way around this is to just mark the render_image
as a dirty NIF. While it is slow, it is not catastrophically slow, so the warning attached to the Erlang documentation for dirty NIFs is not really applicable. We can specify this is a dirty NIF:
#[rustler::nif(schedule = "DirtyCpu")]
fn render_image<'a>(env: Env<'a>, skin_image: Binary<'a>, options: RenderOptions) -> NifResult<Binary<'a>> {
We're done! From here, we can implement all the other endpoints. Want to give it a spin? I have pushed the code for your enjoyment. It still needs cleanup and isn't feature-complete yet, but it will be soon enough...
Did you enjoy it? #
It was better than falling asleep and being like a zombie the following day. Can't complain. Even if I had to fight with the borrow checker.