Skip to main content

Recreating a Secret Santa App in Rust with WASM (Part 2)

· 8 min read
Tony Hallam
Application Consultant @ EPCC

I'm trying to learn Rust and WASM together and this seemed like an interesting project to pursue. This part covers the frontend portion of the project and the integration into Docusaurus.

I've always been annoyed by how SecretSanta apps make you sign up and give them your email and other details. They do make it simple in some ways but more complex in others. Then I found this very cool example. Which uses an encrypted hash to hide details from a human observer but which can then be decrypted to give a person their SecretSanta. Obviously this isn't how encryption was intended to be used because the private key needs to be available to online application to decrypt the hash, but it serves its purpose and means the App doesn't need to know someone's email address or even store their name.

Perfect for a static website like this!

The full code for the Rust implementation is available in this repository. The front end forms part of this website.

Scoping

The second phase of this project was to integrate the WASM package generated in Part 1 into this website as a Docusaurus page. Pages are a way to create custom one-off standalone web pages within your Docusaurus site that contain non-standard content such as a showcase or support page. They can be compiled from React components (including your own) or Markdown.

The key components of the original Secret Santa were split into two pages. A landing with an introduction, form to input the instructions and JS to generate the encoded links. The second page where the links lead to, was a simple message with custom fields that decoded URL query string parameters to provide the viewer with the participant name (their name) and their secret santa match.

There is some complexity to these components, including the form and query strings as well as the WASM integration which mean I decided to implement my frontend using React components.

WASM packages in Docusaurus

Docusaurus uses webpack to bundle files into static assets. Webpack has recently made changes to the way WASM is handled which meant that the feature must be optionally enabled. The way to achieve this in Docusaurus is to write a custom webpack plugin for Docusaurus that modifies the Docusaurus webpack config to enable the feature.

The plugin for this website is called custom-webpack and lives in ./plugins/custom-webpack. There is a simple package.json and an index.js file with the modified config.

index.js
export default async function customWebpack(context, opts) {
return {
name: 'custom-webpack',
configureWebpack(config, isServer, utils, content) {
return {
output: {
webassemblyModuleFilename: "[hash].wasm",
},
module: {
rules: [
{
test: /\.wasm$/,
type: "webassembly/async"
}
]
},
experiments: {
asyncWebAssembly: true,
},
};
},
}
}

The plugin is enabled via the docusaurus.config.ts file.

docusaurus.config.ts
const config: Config = {
...
plugins: [
require.resolve('custom-webpack')
],
...
}

After the bundling step, any WASM files found in packages are included as hash named .wasm files in the root of the website.

Landing Page

The two main components on the landing page are the instructions and the input form. The instructions are just text with some styling and inconsequential. All the magic happens in the form component. Initially I didn't have too much trouble getting the form to work with the WASM package. This was because I was using Docusaurus in its local server mode via

pnpm run start

This command starts a local webserver to serve the content directly and supports hot loading and updates. The trouble started when I went to build the website for deployment to Github pages.

pnpm docusaurus build

WASM & Docusaurus SSR

Docusaurus uses a mixture of server rendering and client side JS assets. The server side rendering of the React components to HTML pages is done using Node and the client side rendering is prepared for the browser. WASM is specifically for the client side and thus incompatible with the Node rending process. For reference, the error I would get came at the Bundling stage after packages had been built. The error refers to webpack's use of __dirname to load WASM modules which is not available outside the browser/DOM context.

It took a lot of troubleshooting a googling to come to that observation. More than I would have liked. The solution it seemed was to use the Docusaurus escape hatches. Specifically, the <BrowserOnly> component to prevent the application from being rendered server side and React's useEffect to load the WASM modules dynamically during app loading.

The top level form component thus becomes a thin wrapper around a rendering function.

export const SecretSantaForm: React.FC = () => {
return (
<BrowserOnly fallback={<div>Loading...</div>}>
{() => { return render() }}
</BrowserOnly>
)
}

The WASM library is loaded within the rendering of the form, not at the top level of the Typescript. The bare bones implementation would look like this. With the variable wasm holding the super-secret-santa module. The empty dependencies array for useEffect means that the module is loaded only once.

function render(): JSX.Element {
const [wasm, setWasm] = useState(null);

useEffect(() => {
import("@trhallam/super-secret-santa").then(setWasm);
}, [])
return (
<></>
)
}
note

Note that the WASM package has been added as a dependency using the site package.json. The package is not served anywhere so it is added from the pkgs folder of the site repository.

"@trhallam/super-secret-santa": "file:../pkgs/super-secret-santa",

Form & WASM

Generation of the Secret Santa links is handled by the form onSubmit handle and a special function. The function is contained within the render function to ensure it has access to the WASM and so that the React state variables can be maintained.

The exposed WASM function get_secret_santa is called from the wasm state variable and passed the full instructions string captured from the form textarea. The WASM is currently wrapped in a try-catch but could be reworked to provide more feedback to users.

function render(): JSX.Element {
const [isGen, setIsGen] = useState(false);
const [instructions, setInstructions] = useState("");
const [pairs, setPairs] = useState<Map<String, Object>>(new Map());
const [wasm, setWasm] = useState(null);

useEffect(() => {
import("@trhallam/super-secret-santa").then(setWasm);
}, [])

function handleSubmit(query): void {
// Prevent the browser from reloading the page
query.preventDefault();

// Read the form data
const form = query.target;
const formData = new FormData(form);

// Or you can work with it as a plain object:
const formJson = Object.fromEntries(formData.entries());
const instr = formJson.input.toString();
let pair_map = new Map();
// pass formJson.input
setInstructions(instr)
try {
pair_map = wasm.get_secret_santas(instr);
} catch (e) {
console.log(e);
console.log("Could not generate pairings.")
return null;
}
// update state variables
setPairs(pair_map);
setIsGen(true);
return null;
}

function handleAlter(): void {
setIsGen(false);
return null;
}

const renderInputOutput = () => {
if (!isGen) {
return emptyForm(handleSubmit, instructions);
} else {
const pair_map = Array.from(
pairs.keys()).map(
(n, index) => (
<SecretLink name={n.valueOf()} secrets={pairs.get(n)} />
)
);
return (filledForm(handleAlter, instructions, pair_map))
}
}

return (
renderInputOutput()
)
}

A second subpage under /super-secret-santa called who is where the secret links are directed to. This page returns nothing meaningful by default (in fact an error). For users to see meaningful content they need to include the correct query string with the URL in their browser. These are the links generated from the instructions by the form.

The who page passes the query string values back to WASM for decoding so that plain text of a persons Secret Santa match can be displayed on the screen. This occurs in the decrypt_secret_santa function. Note that the server rendering must again be avoided in this separate call to WASM.

whomsg.tsx
function render(): JSX.Element {
const location = useLocation();
const params = new URLSearchParams(location.search);
const [wasm, setWasm] = useState(null);
let who = "Unknown";

useEffect(() => {
import("@trhallam/super-secret-santa").then(setWasm);
}, [])

if (wasm) {
try {
let key = params.get('key');
let iv = params.get('iv');
let secret = params.get('secret');
who = wasm.decrypt_secret_santa(key, iv, secret);
} catch (e) {
console.log(e);
return <MsgError error="Could not decode url query!" />
}
}
return (<MsgMatch match={who} name={params.get('name')}></MsgMatch>)
}

export const WhoMsg: React.FC = () => {
return (
<BrowserOnly fallback={<div>Loading...</div>}>
{() => { return render() }}
</BrowserOnly>
)
}

Notes & Issues

Docusaurus and WASM

Integrating WASM packages into Docusaurus was not entirely straight forward. The webpack configuration needed to be modified using a Docusaurus plugin and the places where WASM was imported need to be wrapped in a way to prevent SSR.

Typescript

Because I utilised some browser specific references in my tsx files. The tsconfig.json needed to updated to reflect those missing lib.

tsconfig.json
  "compilerOptions": {
"baseUrl": ".",
"lib": [
"es2021",
"DOM",
"DOM.Iterable",
],
...
}