feat(web/js): add wasm client side runner

Signed-off-by: Xe Iaso <me@xeiaso.net>
This commit is contained in:
Xe Iaso
2025-09-28 02:21:11 +00:00
parent 03a6c07c73
commit a63cbc7ced
14 changed files with 670 additions and 7 deletions

View File

@@ -49,7 +49,7 @@ for file in js/**/*.ts js/**/*.mjs; do
mkdir -p "$(dirname "$out")"
esbuild "$file" --sourcemap --bundle --minify --outfile="$out" --banner:js="$LICENSE"
esbuild "$file" --sourcemap --bundle --outfile="$out" --banner:js="$LICENSE"
gzip -f -k -n "$out"
zstd -f -k --ultra -22 "$out"
brotli -fZk "$out"

View File

@@ -1,6 +1,10 @@
import fast from "./fast";
import wasm from "./wasm";
export default {
fast: fast,
slow: fast, // XXX(Xe): slow is deprecated, but keep this around in case anything goes bad
argon2id: wasm,
sha256: wasm,
}

87
web/js/algorithms/wasm.ts Normal file
View File

@@ -0,0 +1,87 @@
import { u } from "../../lib/xeact";
type ProgressCallback = (nonce: number) => void;
interface ProcessOptions {
basePrefix: string;
version: string;
algorithm: string;
}
const getHardwareConcurrency = () =>
navigator.hardwareConcurrency !== undefined ? navigator.hardwareConcurrency : 1;
export default function process(
options: ProcessOptions,
data: string,
difficulty: number = 5,
signal: AbortSignal | null = null,
progressCallback?: ProgressCallback,
threads: number = Math.trunc(Math.max(getHardwareConcurrency() / 2, 1)),
): Promise<string> {
console.debug(options);
const { basePrefix, version, algorithm } = options;
let wasmFeatures = "baseline";
return new Promise(async (resolve, reject) => {
const module = await fetch(u(`${basePrefix}/.within.website/x/cmd/anubis/static/wasm/${wasmFeatures}/${algorithm}.wasm?cacheBuster=${version}`))
.then(x => WebAssembly.compileStreaming(x));
const webWorkerURL = `${options.basePrefix}/.within.website/x/cmd/anubis/static/js/worker/wasm.mjs?cacheBuster=${version}`;
const workers: Worker[] = [];
let settled = false;
const onAbort = () => {
console.log("PoW aborted");
cleanup();
reject(new DOMException("Aborted", "AbortError"));
};
const cleanup = () => {
if (settled) {
return;
}
settled = true;
workers.forEach((w) => w.terminate());
if (signal != null) {
signal.removeEventListener("abort", onAbort);
}
};
if (signal != null) {
if (signal.aborted) {
return onAbort();
}
signal.addEventListener("abort", onAbort, { once: true });
}
for (let i = 0; i < threads; i++) {
let worker = new Worker(webWorkerURL);
worker.onmessage = (event) => {
if (typeof event.data === "number") {
progressCallback?.(event.data);
} else {
cleanup();
resolve(event.data);
}
}
worker.onerror = (event) => {
cleanup();
reject(event);
}
worker.postMessage({
data,
difficulty,
nonce: i,
threads,
module,
});
}
});
};

View File

@@ -171,7 +171,7 @@ const t = (key) => translations[`js_${key}`] || translations[key] || key;
try {
const t0 = Date.now();
const { hash, nonce } = await process(
{ basePrefix, version: anubisVersion },
{ basePrefix, version: anubisVersion, algorithm: rules.algorithm },
challenge.randomData,
rules.difficulty,
null,

106
web/js/worker/wasm.ts Normal file
View File

@@ -0,0 +1,106 @@
export interface Args {
data: string;
difficulty: number;
nonce: number;
threads: number;
module: BufferSource;
}
interface AnubisExports {
anubis_work: (difficulty: number, initialNonce: number, threads: number) => number;
data_ptr: () => number;
result_hash_ptr: () => number;
result_hash_size: () => number;
set_data_length: (len: number) => void;
memory: WebAssembly.Memory;
}
addEventListener("message", async (event: MessageEvent<Args>) => {
console.log(event.data);
const { data, difficulty, threads, module } = event.data;
let { nonce } = event.data;
const importObject = {
anubis: {
anubis_update_nonce: (nonce: number) => postMessage(nonce),
}
};
if (nonce !== 0) {
importObject.anubis.anubis_update_nonce = (_) => { };
}
const obj = await WebAssembly.instantiate(module, importObject);
const {
anubis_work,
data_ptr,
result_hash_ptr,
result_hash_size,
set_data_length,
memory
} = (obj as unknown as any).exports as unknown as AnubisExports;
function uint8ArrayToHex(arr: Uint8Array) {
return Array.from(arr)
.map((c) => c.toString(16).padStart(2, "0"))
.join("");
}
function hexToUint8Array(hexString: string): Uint8Array {
// Remove whitespace and optional '0x' prefix
hexString = hexString.replace(/\s+/g, '').replace(/^0x/, '');
// Check for valid length
if (hexString.length % 2 !== 0) {
throw new Error('Invalid hex string length');
}
// Check for valid characters
if (!/^[0-9a-fA-F]+$/.test(hexString)) {
throw new Error('Invalid hex characters');
}
// Convert to Uint8Array
const byteArray = new Uint8Array(hexString.length / 2);
for (let i = 0; i < byteArray.length; i++) {
const byteValue = parseInt(hexString.substr(i * 2, 2), 16);
byteArray[i] = byteValue;
}
return byteArray;
}
// Write data to buffer
function writeToBuffer(data: Uint8Array) {
if (data.length > 1024) throw new Error("Data exceeds buffer size");
// Get pointer and create view
const offset = data_ptr();
const buffer = new Uint8Array(memory.buffer, offset, data.length);
// Copy data
buffer.set(data);
// Set data length
set_data_length(data.length);
}
function readFromChallenge() {
const offset = result_hash_ptr();
const buffer = new Uint8Array(memory.buffer, offset, result_hash_size());
return buffer;
}
writeToBuffer(hexToUint8Array(data));
nonce = anubis_work(difficulty, nonce, threads);
const challenge = readFromChallenge();
const result = uint8ArrayToHex(challenge);
postMessage({
hash: result,
difficulty,
nonce,
});
});

129
web/lib/xeact.js Normal file
View File

@@ -0,0 +1,129 @@
/**
* Creates a DOM element, assigns the properties of `data` to it, and appends all `children`.
*
* @type{function(string|Function, Object=, Node|Array.<Node|string>=)}
*/
const h = (name, data = {}, children = []) => {
const result =
typeof name == "function" ? name(data) : Object.assign(document.createElement(name), data);
if (!Array.isArray(children)) {
children = [children];
}
result.append(...children);
return result;
};
/**
* Create a text node.
*
* Equivalent to `document.createTextNode(text)`
*
* @type{function(string): Text}
*/
const t = (text) => document.createTextNode(text);
/**
* Remove all child nodes from a DOM element.
*
* @type{function(Node)}
*/
const x = (elem) => {
while (elem.lastChild) {
elem.removeChild(elem.lastChild);
}
};
/**
* Get all elements with the given ID.
*
* Equivalent to `document.getElementById(name)`
*
* @type{function(string): HTMLElement}
*/
const g = (name) => document.getElementById(name);
/**
* Get all elements with the given class name.
*
* Equivalent to `document.getElementsByClassName(name)`
*
* @type{function(string): HTMLCollectionOf.<Element>}
*/
const c = (name) => document.getElementsByClassName(name);
/** @type{function(string): HTMLCollectionOf.<Element>} */
const n = (name) => document.getElementsByName(name);
/**
* Get all elements matching the given HTML selector.
*
* Matches selectors with `document.querySelectorAll(selector)`
*
* @type{function(string): Array.<HTMLElement>}
*/
const s = (selector) => Array.from(document.querySelectorAll(selector));
/**
* Generate a relative URL from `url`, appending all key-value pairs from `params` as URL-encoded parameters.
*
* @type{function(string=, Object=): string}
*/
const u = (url = "", params = {}) => {
let result = new URL(url, window.location.href);
Object.entries(params).forEach((kv) => {
let [k, v] = kv;
result.searchParams.set(k, v);
});
return result.toString();
};
/**
* Takes a callback to run when all DOM content is loaded.
*
* Equivalent to `window.addEventListener('DOMContentLoaded', callback)`
*
* @type{function(function())}
*/
const r = (callback) => window.addEventListener("DOMContentLoaded", callback);
/**
* Allows a stateful value to be tracked by consumers.
*
* This is the Xeact version of the React useState hook.
*
* @type{function(any): [function(): any, function(any): void]}
*/
const useState = (value = undefined) => {
return [
() => value,
(x) => {
value = x;
},
];
};
/**
* Debounce an action for up to ms milliseconds.
*
* @type{function(number): function(function(any): void)}
*/
const d = (ms) => {
let debounceTimer = null;
return (f) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(f, ms);
};
};
/**
* Parse the contents of a given HTML page element as JSON and
* return the results.
*
* This is useful when using templ to pass complicated data from
* the server to the client via HTML[1].
*
* [1]: https://templ.guide/syntax-and-usage/script-templates/#pass-server-side-data-to-the-client-in-a-html-attribute
*/
const j = (id) => JSON.parse(g(id).textContent);
export { h, t, x, g, j, c, n, u, s, r, useState, d };