Files
mfahampshire 08dc353e82 New TS SDK packages (#6839)
* First sweep packages + some minor tweaking

* Second sweep

* Regenerate lockfile + package.json mods

* Regenerate lockfile again

* Fix CI

* Fix CI again

* All building properly

* unblock

* Tweak examples

* Comments + readme + fix rotten unit test
2026-06-05 10:36:36 +00:00

859 lines
28 KiB
JavaScript

// smolmix-wasm internal-dev test harness.
// WASM runs in a Web Worker so mixnet I/O stays off the main thread.
import * as Comlink from "comlink";
import { MixSocket } from "./mix-socket.js";
function createWorker() {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL("./worker.js", import.meta.url));
worker.addEventListener("error", reject);
worker.addEventListener(
"message",
(msg) => {
if (msg.data?.kind === "Loaded") {
worker.removeEventListener("error", reject);
resolve({ worker, api: Comlink.wrap(worker) });
}
},
{ once: true },
);
});
}
let api = null;
const outputEl = document.getElementById("output");
// Global timeline. Per-section feedback goes through logTo() instead.
// Red-coloured entries (the in-page convention for errors) are also
// mirrored to console.error so they show up in the browser dev tools
// alongside the Rust-side `[smolmix] ...` logs.
function display(msg, colour) {
const ts = new Date().toISOString().slice(11, 23);
const line = document.createElement("div");
if (colour) line.style.color = colour;
line.textContent = `[${ts}] ${msg}`;
outputEl.appendChild(line);
outputEl.scrollTop = outputEl.scrollHeight;
if (colour === "red") console.error("[smolmix-dev]", msg);
}
function logTo(targetId, msg, colour) {
const target = document.getElementById(targetId);
if (!target) return;
const ts = new Date().toISOString().slice(11, 23);
const line = document.createElement("div");
if (colour) line.style.color = colour;
line.textContent = `[${ts}] ${msg}`;
target.appendChild(line);
target.scrollTop = target.scrollHeight;
if (colour === "red") console.error(`[smolmix-dev:${targetId}]`, msg);
}
function hexPreview(data, maxBytes = 64) {
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
const len = Math.min(bytes.length, maxBytes);
const hex = Array.from(bytes.slice(0, len), (b) =>
b.toString(16).padStart(2, "0"),
).join(" ");
return bytes.length > maxBytes ? `${hex} ...` : hex;
}
// Wrap mixFetch's { body, status, statusText, headers } in a native Response
// so callers get .json(), .text(), .arrayBuffer().
function toResponse(raw) {
return new Response(raw.body, {
status: raw.status,
statusText: raw.statusText,
headers: new Headers(raw.headers),
});
}
// Tunnel-gated fieldsets and buttons. GET and DNS fieldsets are excluded:
// their clearnet buttons work without a tunnel, so only the tunnel buttons
// inside them are gated individually.
const GATED_FIELDSETS = [
"ws-controls",
"stress-controls",
"download-controls",
];
const GATED_BUTTONS = ["btn-get-tunnel", "btn-dns-tunnel"];
function setTunnelButtonsEnabled(enabled) {
for (const id of GATED_FIELDSETS) {
document.getElementById(id).disabled = !enabled;
}
for (const id of GATED_BUTTONS) {
document.getElementById(id).disabled = !enabled;
}
}
// Grey out the IPR text input when "Use random IPR" is checked.
document.getElementById("opt-random-ipr").addEventListener("change", (e) => {
document.getElementById("ipr-address").disabled = e.target.checked;
});
document.getElementById("btn-setup").addEventListener("click", async () => {
const useRandomIpr = document.getElementById("opt-random-ipr").checked;
const iprAddress = useRandomIpr
? undefined
: document.getElementById("ipr-address").value.trim();
if (!useRandomIpr && !iprAddress) {
display("IPR address is required (or check 'Use random IPR')", "red");
return;
}
const statusEl = document.getElementById("tunnel-status");
document.getElementById("btn-setup").disabled = true;
statusEl.textContent = "Starting worker...";
statusEl.style.color = "orange";
try {
const result = await createWorker();
api = result.api;
MixSocket._initWorker(result.worker);
display("Worker started");
// Sync the debug-logging checkbox into WASM so the UI default applies
// from setup time — without this, the checkbox could read `checked`
// while the WASM static stays false until the user re-toggles.
const debugOn = document.getElementById("opt-debug-logging").checked;
await api.setDebugLogging(debugOn);
} catch (e) {
display(`Worker creation failed: ${e}`, "red");
document.getElementById("btn-setup").disabled = false;
statusEl.textContent = "Failed";
statusEl.style.color = "red";
return;
}
const clientId = document.getElementById("opt-client-id").value;
const forceTls = document.getElementById("opt-force-tls").checked;
const disablePoisson = document.getElementById("opt-disable-poisson").checked;
const disableCover = document.getElementById("opt-disable-cover").checked;
// Allow 0 for the data slot — the default is "lean entirely on
// nym-client-core's pre-emptive SURB topup" (see SurbsConfig::default
// in wasm/smolmix/src/ipr.rs).
const clampSurbs = (n) => Math.min(50, Math.max(0, n));
const openReplySurbs = clampSurbs(
parseInt(document.getElementById("opt-open-surbs").value, 10) || 10,
);
const dataReplySurbs = clampSurbs(
parseInt(document.getElementById("opt-data-surbs").value, 10) || 0,
);
// `undefined` (omitted) means "use the Rust default"; see SetupOpts.
const primaryDns =
document.getElementById("opt-primary-dns").value.trim() || undefined;
const fallbackDns =
document.getElementById("opt-fallback-dns").value.trim() || undefined;
display(
useRandomIpr
? `setupMixTunnel (clientId=${clientId}, IPR: auto-discover)...`
: `setupMixTunnel (clientId=${clientId}, IPR: ${iprAddress.slice(0, 30)}...)...`,
);
statusEl.textContent = "Connecting to mixnet...";
try {
await api.setupMixTunnel({
// Omit preferredIpr entirely when auto-discovery is requested.
// Rust SetupOpts treats `null`/undefined as "discover one yourself".
...(iprAddress ? { preferredIpr: iprAddress } : {}),
clientId,
forceTls,
disablePoissonTraffic: disablePoisson,
disableCoverTraffic: disableCover,
openReplySurbs,
dataReplySurbs,
primaryDns,
fallbackDns,
});
display("setupMixTunnel OK: tunnel ready", "green");
statusEl.textContent = "Connected";
statusEl.style.color = "green";
setTunnelButtonsEnabled(true);
document.getElementById("btn-disconnect").disabled = false;
} catch (e) {
const msg = String(e);
display(`setupMixTunnel failed: ${msg}`, "red");
statusEl.textContent = `Failed: ${msg}`;
statusEl.style.color = "red";
statusEl.title = msg;
document.getElementById("btn-setup").disabled = false;
}
});
document
.getElementById("btn-disconnect")
.addEventListener("click", async () => {
display("Disconnecting...");
try {
await api.disconnectMixTunnel();
display("Disconnected", "green");
document.getElementById("tunnel-status").textContent = "Disconnected";
document.getElementById("tunnel-status").style.color = "gray";
setTunnelButtonsEnabled(false);
document.getElementById("btn-disconnect").disabled = true;
document.getElementById("btn-setup").disabled = true; // OnceLock: can't reinit
} catch (e) {
display(`Disconnect failed: ${e}`, "red");
}
});
document
.getElementById("opt-debug-logging")
.addEventListener("change", async (e) => {
const enabled = e.target.checked;
// Pre-setup clicks: WASM isn't loaded yet. The checkbox state is read
// again right after the worker boots, so the toggle still takes effect.
if (!api) {
display(`Debug logging queued ${enabled ? "ON" : "OFF"} (applies on setup)`);
return;
}
try {
await api.setDebugLogging(enabled);
display(
`Debug logging ${enabled ? "ON" : "OFF"} (logs in browser console)`,
enabled ? "green" : "gray",
);
} catch (err) {
display(`setDebugLogging failed: ${err}`, "red");
}
});
document.getElementById("btn-dns-tunnel").addEventListener("click", async () => {
const hostname = document.getElementById("dns-host").value.trim();
if (!hostname) {
logTo("dns-log", "Hostname is required", "red");
return;
}
const btn = document.getElementById("btn-dns-tunnel");
btn.disabled = true;
logTo("dns-log", `tunnel resolve ${hostname}`);
const t0 = performance.now();
try {
const ip = await api.mixDNS(hostname);
const ms = (performance.now() - t0).toFixed(0);
logTo("dns-log", `tunnel ${hostname} => ${ip} (${ms} ms)`, "green");
} catch (e) {
logTo("dns-log", `tunnel resolve failed: ${e}`, "red");
} finally {
btn.disabled = false;
}
});
// The browser exposes no raw DNS API; "clearnet DNS" from JS is DoH (HTTPS)
// to a public resolver. Google's JSON API is CORS-friendly and returns
// { Status, Answer: [{ name, type, TTL, data }] }, where type=1 is an A
// record. The request appears in DevTools Network as an HTTPS fetch.
document.getElementById("btn-dns-clearnet").addEventListener("click", async () => {
const hostname = document.getElementById("dns-host").value.trim();
if (!hostname) {
logTo("dns-log", "Hostname is required", "red");
return;
}
logTo("dns-log", `clearnet DoH resolve ${hostname}`);
const t0 = performance.now();
try {
const resp = await window.fetch(
`https://dns.google/resolve?name=${encodeURIComponent(hostname)}&type=A`,
{ mode: "cors" },
);
const json = await resp.json();
const ms = (performance.now() - t0).toFixed(0);
if (json.Status !== 0) {
logTo("dns-log", `clearnet DoH error: status=${json.Status} (${ms} ms)`, "red");
return;
}
const aRecord = json.Answer?.find((a) => a.type === 1);
if (!aRecord) {
logTo("dns-log", `clearnet DoH: no A record (${ms} ms)`, "orange");
return;
}
logTo(
"dns-log",
`clearnet ${hostname} => ${aRecord.data} (${ms} ms); visible in DevTools Network`,
"green",
);
} catch (e) {
logTo("dns-log", `clearnet DoH failed: ${e}`, "red");
}
});
// Same URL, two transports. Filter DevTools Network by the target host:
// clearnet produces a row, tunnel does not.
document.getElementById("btn-get-tunnel").addEventListener("click", async () => {
const url = document.getElementById("get-url").value.trim();
if (!url) {
logTo("get-log", "URL is required", "red");
return;
}
logTo("get-log", `tunnel GET ${url}`);
const t0 = performance.now();
try {
const raw = await api.mixFetch(url, {});
const resp = toResponse(raw);
const ms = (performance.now() - t0).toFixed(0);
logTo("get-log", `tunnel ${resp.status} ${resp.statusText} (${ms} ms)`, "green");
} catch (e) {
logTo("get-log", `tunnel GET failed: ${e}`, "red");
}
});
document.getElementById("btn-get-clearnet").addEventListener("click", async () => {
const url = document.getElementById("get-url").value.trim();
if (!url) {
logTo("get-log", "URL is required", "red");
return;
}
logTo("get-log", `clearnet GET ${url}`);
const t0 = performance.now();
try {
const resp = await window.fetch(url, { mode: "cors" });
const ms = (performance.now() - t0).toFixed(0);
logTo(
"get-log",
`clearnet ${resp.status} ${resp.statusText} (${ms} ms); visible in DevTools Network`,
"green",
);
} catch (e) {
logTo("get-log", `clearnet fetch failed: ${e}`, "red");
}
});
function formatSize(bytes) {
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + " MB";
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + " KB";
return bytes + " B";
}
function formatRate(bytes, ms) {
const kbps = bytes / 1024 / (ms / 1000);
return kbps.toFixed(1) + " KB/s";
}
let activeWs = null;
let wsConnectT0 = 0;
// Queue of send timestamps for RTT tracking.
// WebSocket preserves message order, so each recv pops the oldest send.
const wsSendQueue = [];
let wsBurstActive = false;
let wsBurstRtts = [];
let wsBurstExpected = 0;
let wsBurstResolve = null;
function setWsButtonState(state) {
const connected = state === "connected";
const connecting = state === "connecting";
document.getElementById("btn-ws-connect").disabled = connected || connecting;
document.getElementById("btn-ws-send").disabled = !connected;
document.getElementById("btn-ws-close").disabled = !connected;
document.getElementById("btn-ws-burst").disabled = !connected;
}
document.getElementById("btn-ws-connect").addEventListener("click", () => {
const url = document.getElementById("ws-url").value.trim();
if (!url) {
logTo("ws-log", "WebSocket URL is required", "red");
return;
}
// Tear down any prior connection so a rapid double-click doesn't leak it.
if (activeWs && activeWs.readyState !== MixSocket.CLOSED) {
activeWs.close();
}
const statusEl = document.getElementById("ws-status");
statusEl.textContent = "Connecting...";
statusEl.style.color = "orange";
setWsButtonState("connecting");
wsSendQueue.length = 0;
logTo("ws-log", `connecting to ${url}`);
wsConnectT0 = performance.now();
const ws = new MixSocket(url);
ws.onopen = () => {
const ms = (performance.now() - wsConnectT0).toFixed(0);
logTo("ws-log", `connected in ${ms} ms (protocol=${ws.protocol || "none"})`, "green");
statusEl.textContent = `Connected (${ms} ms)`;
statusEl.style.color = "green";
setWsButtonState("connected");
};
ws.onmessage = (e) => {
let preview;
if (typeof e.data === "string") {
preview = e.data.length <= 200 ? e.data : e.data.slice(0, 200) + "...";
} else if (e.data instanceof ArrayBuffer) {
preview = `[binary ${e.data.byteLength} bytes] ${hexPreview(e.data)}`;
} else if (e.data instanceof Blob) {
preview = `[blob ${e.data.size} bytes]`;
} else {
preview = `[unknown ${typeof e.data}]`;
}
let rttMs = null;
if (wsSendQueue.length > 0) {
rttMs = performance.now() - wsSendQueue.shift();
}
// During burst: collect RTTs silently, don't log each message
if (wsBurstActive) {
if (rttMs != null) wsBurstRtts.push(rttMs);
if (wsBurstRtts.length >= wsBurstExpected && wsBurstResolve) {
wsBurstResolve();
}
return;
}
if (rttMs != null) {
logTo("ws-log", `recv (${rttMs.toFixed(0)} ms RTT): ${preview}`, "green");
} else {
logTo("ws-log", `recv: ${preview}`, "green");
}
};
ws.onclose = (e) => {
logTo(
"ws-log",
`closed: ${e.code} ${e.reason}${e.wasClean ? "" : " (unclean)"}`,
"orange",
);
statusEl.textContent = "Closed";
statusEl.style.color = "gray";
setWsButtonState("disconnected");
activeWs = null;
};
ws.onerror = () => {
logTo("ws-log", "error", "red");
statusEl.textContent = "Error";
statusEl.style.color = "red";
};
activeWs = ws;
});
document.getElementById("btn-ws-send").addEventListener("click", () => {
if (!activeWs || activeWs.readyState !== MixSocket.OPEN) return;
const msg = document.getElementById("ws-message").value;
wsSendQueue.push(performance.now());
activeWs.send(msg);
logTo("ws-log", `send: ${msg}`);
});
document.getElementById("btn-ws-close").addEventListener("click", () => {
if (!activeWs) return;
const t0 = performance.now();
activeWs.onclose = (e) => {
const ms = (performance.now() - t0).toFixed(0);
logTo(
"ws-log",
`closed in ${ms} ms: ${e.code} ${e.reason}${e.wasClean ? "" : " (unclean)"}`,
"orange",
);
document.getElementById("ws-status").textContent = "Closed";
document.getElementById("ws-status").style.color = "gray";
setWsButtonState("disconnected");
activeWs = null;
};
logTo("ws-log", "closing...");
activeWs.close();
});
document.getElementById("btn-ws-burst").addEventListener("click", async () => {
if (!activeWs || activeWs.readyState !== MixSocket.OPEN) return;
const count = parseInt(document.getElementById("ws-burst-count").value, 10);
const minSize = parseInt(document.getElementById("ws-burst-min").value, 10);
const maxSize = parseInt(document.getElementById("ws-burst-max").value, 10);
if (count < 1 || count > 500) {
logTo("ws-log", "burst count must be 1-500", "red");
return;
}
if (minSize < 1 || maxSize < minSize) {
logTo("ws-log", "invalid size range", "red");
return;
}
// Switch to arraybuffer mode for binary round-trip verification
const prevBinaryType = activeWs.binaryType;
activeWs.binaryType = "arraybuffer";
document.getElementById("btn-ws-burst").disabled = true;
document.getElementById("btn-ws-send").disabled = true;
const payloads = [];
let totalBytes = 0;
for (let i = 0; i < count; i++) {
const size =
minSize === maxSize
? minSize
: minSize + Math.floor(Math.random() * (maxSize - minSize + 1));
const buf = new Uint8Array(size);
crypto.getRandomValues(buf);
payloads.push(buf);
totalBytes += size;
}
logTo(
"ws-log",
`echo burst: ${count} msgs, ${formatSize(minSize)}-${formatSize(maxSize)} (${formatSize(totalBytes)} total)`,
);
wsBurstActive = true;
wsBurstRtts = [];
wsBurstExpected = count;
let received = 0;
let verified = 0;
let mismatches = 0;
const sizes = [];
let firstRecvHex = null;
const burstDone = new Promise((resolve) => {
wsBurstResolve = resolve;
const origOnmessage = activeWs.onmessage;
activeWs.onmessage = (e) => {
let rttMs = null;
if (wsSendQueue.length > 0) {
rttMs = performance.now() - wsSendQueue.shift();
wsBurstRtts.push(rttMs);
}
const sent = payloads[received];
const recvBuf = new Uint8Array(e.data);
sizes.push(recvBuf.byteLength);
if (firstRecvHex === null) firstRecvHex = hexPreview(recvBuf);
if (sent && recvBuf.byteLength === sent.byteLength) {
let match = true;
for (let j = 0; j < sent.byteLength; j++) {
if (recvBuf[j] !== sent[j]) {
match = false;
break;
}
}
if (match) verified++;
else mismatches++;
} else {
mismatches++;
}
received++;
if (received >= count) {
activeWs.onmessage = origOnmessage;
resolve();
}
};
});
const t0 = performance.now();
for (let i = 0; i < count; i++) {
wsSendQueue.push(performance.now());
activeWs.send(payloads[i]);
}
await burstDone;
const totalMs = performance.now() - t0;
wsBurstActive = false;
wsBurstResolve = null;
activeWs.binaryType = prevBinaryType;
const rtts = wsBurstRtts.slice().sort((a, b) => a - b);
const rttMin = rtts[0].toFixed(0);
const rttMax = rtts[rtts.length - 1].toFixed(0);
const rttAvg = (rtts.reduce((a, b) => a + b, 0) / rtts.length).toFixed(0);
const p50 = rtts[Math.floor(rtts.length * 0.5)].toFixed(0);
const p95 = rtts[Math.floor(rtts.length * 0.95)].toFixed(0);
const msgPerSec = (count / (totalMs / 1000)).toFixed(1);
const throughput = formatRate(totalBytes, totalMs);
const verifyColour = mismatches === 0 ? "green" : "red";
logTo(
"ws-log",
`burst done: ${count} msgs in ${(totalMs / 1000).toFixed(2)}s (${msgPerSec} msg/s, ${throughput})`,
"green",
);
logTo(
"ws-log",
`verify: ${verified}/${count} OK` + (mismatches > 0 ? `, ${mismatches} MISMATCH` : ""),
verifyColour,
);
logTo(
"ws-log",
`RTT: min=${rttMin} avg=${rttAvg} p50=${p50} p95=${p95} max=${rttMax} ms`,
);
document.getElementById("btn-ws-burst").disabled = false;
document.getElementById("btn-ws-send").disabled = false;
});
const SIZE_PROFILES = [
{ label: "tiny", bytes: 128 },
{ label: "small", bytes: 1024 },
{ label: "medium", bytes: 10240 },
{ label: "large", bytes: 102400 },
{ label: "xlarge", bytes: 1048576 },
];
function buildDripProfiles(timeoutSec) {
return [
{ label: "safe", duration: Math.round(timeoutSec * 0.5), delay: 0, bytes: 100 },
{ label: "boundary", duration: Math.round(timeoutSec * 0.92), delay: 0, bytes: 100 },
{ label: "over", duration: Math.round(timeoutSec * 1.08), delay: 0, bytes: 100 },
{ label: "slow-start", duration: Math.round(timeoutSec * 0.83), delay: Math.round(timeoutSec * 0.17), bytes: 100 },
];
}
function generateRequests(count, mode, timeoutSec) {
const requests = [];
if (mode === "uniform") {
const baseUrl = document.getElementById("stress-url").value.trim();
for (let i = 1; i <= count; i++) {
requests.push({ id: i, url: `${baseUrl}${i}`, label: "uniform" });
}
} else if (mode === "mixed") {
for (let i = 1; i <= count; i++) {
const p = SIZE_PROFILES[Math.floor(Math.random() * SIZE_PROFILES.length)];
requests.push({ id: i, url: `https://httpbin.org/bytes/${p.bytes}`, label: p.label });
}
} else if (mode === "drip") {
const profiles = buildDripProfiles(timeoutSec);
for (let i = 1; i <= count; i++) {
const p = profiles[Math.floor(Math.random() * profiles.length)];
requests.push({
id: i,
url: `https://httpbin.org/drip?duration=${p.duration}&numbytes=${p.bytes}&delay=${p.delay}&code=200`,
label: p.label,
});
}
}
return requests;
}
async function runOneStressRequest(req) {
const tag = `#${req.id} ${req.label}`;
const start = performance.now();
try {
const raw = await api.mixFetch(req.url, {});
const resp = toResponse(raw);
const body = await resp.text();
const elapsed = ((performance.now() - start) / 1000).toFixed(2);
logTo("stress-log", `[${tag}] ${resp.status} OK ${elapsed}s (${body.length}B)`, "green");
return { id: req.id, label: req.label, ok: true, status: resp.status, elapsed, textLength: body.length };
} catch (e) {
const elapsed = ((performance.now() - start) / 1000).toFixed(2);
logTo("stress-log", `[${tag}] FAIL ${elapsed}s: ${e}`, "red");
return { id: req.id, label: req.label, ok: false, elapsed, error: String(e) };
}
}
document.getElementById("btn-stress").addEventListener("click", async () => {
const count = parseInt(document.getElementById("stress-count").value, 10);
const mode = document.getElementById("stress-mode").value;
const timeoutSec = parseInt(
(document.getElementById("stress-timeout") || { value: "60" }).value,
10,
);
const statusEl = document.getElementById("stress-status");
document.getElementById("btn-stress").disabled = true;
statusEl.textContent = "Running...";
const requests = generateRequests(count, mode, timeoutSec);
if (mode === "mixed" || mode === "drip") {
const breakdown = {};
for (const r of requests) breakdown[r.label] = (breakdown[r.label] || 0) + 1;
logTo("stress-log", `${count} requests, ${mode} mode, profiles: ${JSON.stringify(breakdown)}`);
} else {
logTo("stress-log", `${count} requests, ${mode} mode`);
}
const t0 = performance.now();
const settled = await Promise.allSettled(
requests.map((r) => runOneStressRequest(r)),
);
const totalSec = ((performance.now() - t0) / 1000).toFixed(2);
const results = settled.map((s) =>
s.status === "fulfilled" ? s.value : { ok: false, error: s.reason },
);
const ok = results.filter((r) => r.ok).length;
const fail = results.filter((r) => !r.ok).length;
const colour = fail === 0 ? "green" : "red";
logTo("stress-log", `done: ${ok}/${count} OK, ${fail} failed (${totalSec}s total)`, colour);
if (fail > 0) {
for (const r of results.filter((r) => !r.ok)) {
logTo("stress-log", ` FAIL #${r.id} ${r.label} (${r.elapsed}s): ${r.error}`);
}
}
statusEl.textContent = `Done: ${ok}/${count} OK, ${fail} failed (${totalSec}s)`;
document.getElementById("btn-stress").disabled = false;
});
document.getElementById("stress-mode").addEventListener("change", function () {
document.getElementById("stress-uniform-opts").style.display =
this.value === "uniform" ? "block" : "none";
document.getElementById("stress-mixed-opts").style.display =
this.value === "mixed" ? "block" : "none";
document.getElementById("stress-drip-opts").style.display =
this.value === "drip" ? "block" : "none";
});
const VERIFY_TEXT_URL =
"https://www.cl.cam.ac.uk/~mgk25/ucs/examples/UTF-8-demo.txt";
async function sha256hex(bytes) {
const hash = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(new Uint8Array(hash), (b) =>
b.toString(16).padStart(2, "0"),
).join("");
}
function saveFile(buf, filename, mimeType) {
const blob = new Blob([buf], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
let cachedPdf = null;
async function verifyText() {
const statusEl = document.getElementById("verify-text-status");
const outputEl = document.getElementById("verify-text-output");
document.getElementById("btn-verify-text").disabled = true;
statusEl.textContent = "Fetching...";
statusEl.style.color = "orange";
const t0 = performance.now();
try {
const raw = await api.mixFetch(VERIFY_TEXT_URL, {});
const resp = toResponse(raw);
const text = await resp.text();
const ms = (performance.now() - t0).toFixed(0);
statusEl.textContent = `${formatSize(text.length)} in ${ms} ms`;
statusEl.style.color = "green";
outputEl.style.display = "block";
outputEl.textContent = text;
logTo("download-log", `UTF-8 demo: ${formatSize(text.length)} in ${ms} ms`, "green");
} catch (e) {
statusEl.textContent = `Failed: ${e}`;
statusEl.style.color = "red";
logTo("download-log", `UTF-8 demo FAILED: ${e}`, "red");
}
document.getElementById("btn-verify-text").disabled = false;
}
async function fetchFile() {
const url = document.getElementById("download-url").value.trim();
if (!url) {
logTo("download-log", "Download URL is required", "red");
return;
}
const statusEl = document.getElementById("verify-pdf-status");
const outputEl = document.getElementById("verify-pdf-output");
document.getElementById("btn-verify-pdf").disabled = true;
document.getElementById("btn-save-pdf").disabled = true;
cachedPdf = null;
statusEl.textContent = "Fetching...";
statusEl.style.color = "orange";
const t0 = performance.now();
try {
const raw = await api.mixFetch(url, {});
const resp = toResponse(raw);
const buf = await resp.arrayBuffer();
const ms = (performance.now() - t0).toFixed(0);
const hash = await sha256hex(buf);
document.getElementById("verify-pdf-size").textContent =
`${buf.byteLength.toLocaleString()} bytes`;
document.getElementById("verify-pdf-sha").textContent = hash;
statusEl.textContent = `${formatSize(buf.byteLength)} in ${(parseFloat(ms) / 1000).toFixed(1)}s`;
statusEl.style.color = "green";
outputEl.style.display = "block";
cachedPdf = buf;
document.getElementById("btn-save-pdf").disabled = false;
logTo(
"download-log",
`${formatSize(buf.byteLength)} in ${(parseFloat(ms) / 1000).toFixed(1)}s (${formatRate(buf.byteLength, parseFloat(ms))}); SHA-256: ${hash.slice(0, 16)}...`,
"green",
);
} catch (e) {
statusEl.textContent = `Failed: ${e}`;
statusEl.style.color = "red";
logTo("download-log", `FAILED: ${e}`, "red");
}
document.getElementById("btn-verify-pdf").disabled = false;
}
document.getElementById("btn-verify-text").addEventListener("click", verifyText);
document.getElementById("btn-verify-pdf").addEventListener("click", fetchFile);
document.getElementById("btn-save-pdf").addEventListener("click", () => {
if (!cachedPdf) return;
const url = document.getElementById("download-url").value.trim();
const filename = url.split("/").pop()?.split("?")[0] || "download";
saveFile(cachedPdf, filename, "application/octet-stream");
});
document
.getElementById("btn-verify-all")
.addEventListener("click", async () => {
const statusEl = document.getElementById("verify-all-status");
statusEl.textContent = "Running...";
statusEl.style.color = "orange";
logTo("download-log", "running both downloads...");
const t0 = performance.now();
await Promise.allSettled([verifyText(), fetchFile()]);
const totalMs = (performance.now() - t0).toFixed(0);
statusEl.textContent = `Done in ${(parseFloat(totalMs) / 1000).toFixed(1)}s`;
statusEl.style.color = "green";
logTo(
"download-log",
`both complete in ${(parseFloat(totalMs) / 1000).toFixed(1)}s`,
"green",
);
});
// Randomise client ID on each page load for clean state
document.getElementById("opt-client-id").value =
"smolmix-" + Math.random().toString(36).slice(2, 8);
display(
"smolmix-wasm dev ready. Enter an IPR address and click setupMixTunnel. The clearnet GET works without setup.",
);