Recreating a Secret Santa App in Rust with WASM (Part 2)
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.
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.
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 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()
)
}
Who (the secret link)
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.
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.
"compilerOptions": {
"baseUrl": ".",
"lib": [
"es2021",
"DOM",
"DOM.Iterable",
],
...
}