diff --git a/wasm/mix-fetch/internal-dev/index.html b/wasm/mix-fetch/internal-dev/index.html index 558751a743..55a727ae6c 100644 --- a/wasm/mix-fetch/internal-dev/index.html +++ b/wasm/mix-fetch/internal-dev/index.html @@ -3,100 +3,121 @@ - Nym MixFetch Demo + Nym MixFetch Dev -

Mix Fetch Demo

+

MixFetch Dev

- MixFetch Configuration -

- You can either use the default Gateway/NR combo run by us, or have - MixFetch choose a random one on startup. -

-
- + Connection +
+
-
- +
+ +
+
+ + Not started
- - Not started
-
-
- Fetch Controls + Quick Fetch
- - - + +
-
- - - +
+ +
-

- Note: if you're hammering these endpoints and you start to get timeouts - (or you've been reloading the page a lot) then change the target hosts - to e.g. http://ipv4.icanhazip.com and https://ipv6.icanhazip.com/ -

+ + +
+ +

Stress Test

- -

- This does what is says on the tin - sends 10 fetch requests to - https://jsonplaceholder.typicode.com/posts/ 1 through 10 + + + + +

+ + -
-
- - -
-
- - -
-
- -
-

Do a POST and get it echoed back

-
-
-

- -

+
+

+ Each request randomly assigned a size via httpbin.org/bytes/{n}. + Tests how the mixnet handles asymmetric payload sizes concurrently. +

+ + + + + + +
tiny128 B
small1 KB
medium10 KB
large100 KB
xlarge1 MB
+
+ + + +
+ + +
+ +
+ + +
+ + diff --git a/wasm/mix-fetch/internal-dev/index.js b/wasm/mix-fetch/internal-dev/index.js index e2b614ab23..ef99c8e98c 100644 --- a/wasm/mix-fetch/internal-dev/index.js +++ b/wasm/mix-fetch/internal-dev/index.js @@ -12,225 +12,340 @@ // See the License for the specific language governing permissions and // limitations under the License. +// ─── Worker client ────────────────────────────────────────────────────────── + class WebWorkerClient { - worker = null; + worker = null; - constructor() { - this.worker = new Worker('./worker.js'); + constructor() { + this.worker = new Worker("./worker.js"); - this.worker.onmessage = (ev) => { - if (ev.data && ev.data.kind) { - switch (ev.data.kind) { - case 'DisplayString': - const { rawString } = ev.data.args; - displayReceivedRawString(rawString) - break; - case 'Log': - const { message, level } = ev.data.args; - displayLog(message, level); - break; - case 'MixFetchReady': - onMixFetchReady(); - break; - case 'MixFetchError': - const { error } = ev.data.args; - onMixFetchError(error); - break; - } - } - }; - } - - startMixFetch = (preferredGateway) => { - if (!this.worker) { - console.error('Could not send message because worker does not exist'); - return; + this.worker.onmessage = (ev) => { + if (!ev.data || !ev.data.kind) return; + switch (ev.data.kind) { + case "DisplayString": + appendFetchLog(ev.data.args.rawString); + console.log("[mixfetch response]", ev.data.args.rawString); + break; + case "Log": { + const { message, level } = ev.data.args; + const fn = level === "error" ? console.error + : level === "warn" ? console.warn + : console.log; + fn(`[worker/${level}]`, message); + break; } + case "MixFetchReady": + onMixFetchReady(); + break; + case "MixFetchError": + onMixFetchError(ev.data.args.error); + break; + case "StressTestFetchResult": + onStressTestFetchResult(ev.data.args); + break; + } + }; + } - this.worker.postMessage({ - kind: 'StartMixFetch', - args: { - preferredGateway, - }, - }); - } - - doFetch = (target) => { - if (!this.worker) { - console.error('Could not send message because worker does not exist'); - return; - } - - this.worker.postMessage({ - kind: 'FetchPayload', - args: { - target, - }, - }); - } - - doPost = (url, body) => { - if (!this.worker) { - console.error('Could not send message because worker does not exist'); - return; - } - - this.worker.postMessage({ - kind: 'PostPayload', - args: { - url, - body, - }, - }); + startMixFetch = (preferredGateway) => { + this.worker.postMessage({ kind: "StartMixFetch", args: { preferredGateway } }); + }; + + doFetch = (target) => { + this.worker.postMessage({ kind: "FetchPayload", args: { target } }); + }; + + setGoTimeout = (timeoutMs) => { + this.worker.postMessage({ kind: "SetGoTimeout", args: { timeoutMs } }); + }; + + doStressTest = (requests) => { + for (const req of requests) { + this.worker.postMessage({ + kind: "StressTestFetch", + args: { id: req.id, url: req.url, label: req.label }, + }); } + }; } -let client = null; +// ─── Startup ──────────────────────────────────────────────────────────────── +let client = null; const DEFAULT_GATEWAY = "q2A2cbooyC16YJzvdYaSMH9X3cSiieZNtfBr8cE8Fi1"; async function main() { - client = new WebWorkerClient(); + client = new WebWorkerClient(); - const startButton = document.querySelector('#start-mixfetch'); - startButton.onclick = function () { - const gatewayMode = document.querySelector('input[name="gateway-mode"]:checked').value; - const preferredGateway = gatewayMode === 'default' ? DEFAULT_GATEWAY : undefined; + document.querySelector("#start-mixfetch").onclick = () => { + const gatewayMode = document.querySelector('input[name="gateway-mode"]:checked').value; + const preferredGateway = gatewayMode === "default" ? DEFAULT_GATEWAY : undefined; - startButton.disabled = true; - document.querySelectorAll('input[name="gateway-mode"]').forEach(r => r.disabled = true); - updateStatus('Starting...', 'orange'); + document.querySelector("#start-mixfetch").disabled = true; + document.querySelectorAll('input[name="gateway-mode"]').forEach((r) => (r.disabled = true)); + updateStatus("mixfetch-status", "Starting...", "orange"); - displayLog(`Starting MixFetch with ${gatewayMode} gateway${preferredGateway ? ` (${preferredGateway})` : ''}...`, 'info'); - client.startMixFetch(preferredGateway); + console.log(`Starting MixFetch (${gatewayMode} gateway${preferredGateway ? `: ${preferredGateway}` : ""})...`); + client.startMixFetch(preferredGateway); + }; + + document.querySelector("#fetch-button-1").onclick = () => doFetch(1); + document.querySelector("#fetch-button-2").onclick = () => doFetch(2); + + const stressModeSelect = document.getElementById("stress-test-mode"); + stressModeSelect.onchange = 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"; + }; + + document.querySelector("#stress-test-button").onclick = () => { + const count = parseInt(document.getElementById("stress-test-count").value, 10); + const mode = document.getElementById("stress-test-mode").value; + const goTimeoutMs = parseInt(document.getElementById("stress-go-timeout").value, 10); + + document.querySelector("#stress-test-button").disabled = true; + updateStatus("stress-test-status", "Running...", "orange"); + client.setGoTimeout(goTimeoutMs); + + const requests = generateStressRequests(count, mode); + stressTest = { + count, + mode, + startTime: performance.now(), + results: [], + completionOrder: [], + profiles: {}, + }; + for (const req of requests) { + stressTest.profiles[req.id] = { label: req.label, bytes: req.bytes }; } - const fetchButton1 = document.querySelector('#fetch-button-1'); - fetchButton1.onclick = function () { - doFetch(1); + initStressTracker(requests); + + console.log(`%c=== STRESS TEST: ${count} requests, ${mode} mode, timeout=${goTimeoutMs}ms ===`, "font-weight: bold"); + + if (mode === "mixed" || mode === "drip") { + const breakdown = {}; + for (const req of requests) breakdown[req.label] = (breakdown[req.label] || 0) + 1; + console.log("Profiles:", breakdown); } - const fetchButton2 = document.querySelector('#fetch-button-2'); - fetchButton2.onclick = function () { - doFetch(2); - } - - const fetch10Button = document.querySelector('#fetch-10-concurrent'); - fetch10Button.onclick = function () { - doFetch10Concurrent(); - } - - const postButton = document.querySelector('#post-button'); - postButton.onclick = function () { - doPost(); - } + client.doStressTest(requests); + }; } -function updateStatus(text, color) { - const status = document.getElementById('mixfetch-status'); - status.textContent = text; - status.style.color = color; +// ─── UI helpers ───────────────────────────────────────────────────────────── + +function updateStatus(elementId, text, color) { + const el = document.getElementById(elementId); + el.textContent = text; + el.style.color = color; } function onMixFetchReady() { - updateStatus('Ready', 'green'); - document.getElementById('fetch-controls').disabled = false; - displayLog('MixFetch is ready!', 'info'); + updateStatus("mixfetch-status", "Ready", "green"); + document.getElementById("fetch-controls").disabled = false; + console.log("%cMixFetch ready!", "color: green; font-weight: bold"); } function onMixFetchError(error) { - updateStatus('Error: ' + error, 'red'); - document.querySelector('#start-mixfetch').disabled = false; - document.querySelectorAll('input[name="gateway-mode"]').forEach(r => r.disabled = false); - displayLog('MixFetch error: ' + error, 'error'); + updateStatus("mixfetch-status", "Error: " + error, "red"); + document.querySelector("#start-mixfetch").disabled = false; + document.querySelectorAll('input[name="gateway-mode"]').forEach((r) => (r.disabled = false)); + console.error("MixFetch error:", error); } +// ─── Quick fetch ──────────────────────────────────────────────────────────── + +function appendFetchLog(text) { + const log = document.getElementById("fetch-log"); + log.style.display = "block"; + const ts = new Date().toISOString().substr(11, 12); + log.textContent += `${ts} ${text}\n`; + log.scrollTop = log.scrollHeight; +} async function doFetch(id) { - const payload = document.getElementById(`fetch_payload_${id}`).value; - await client.doFetch(payload) - - displaySend(`[${id}] clicked the button and the payload is: ${payload}...`); + const url = document.getElementById(`fetch_payload_${id}`).value; + appendFetchLog(`GET ${url}`); + console.log(`GET ${url}`); + await client.doFetch(url); } -async function doFetch10Concurrent() { - const baseUrl = 'https://jsonplaceholder.typicode.com/posts/'; - displaySend('Starting 10 concurrent requests to posts/1-10...'); +// ─── Stress test ──────────────────────────────────────────────────────────── - const requests = []; - for (let i = 1; i <= 10; i++) { - const url = `${baseUrl}${i}`; - displaySend(`[${i}] Sending request to ${url}`); - requests.push(client.doFetch(url)); +const STRESS_PROFILES = [ + { label: "tiny", bytes: 128 }, + { label: "small", bytes: 1024 }, + { label: "medium", bytes: 10240 }, + { label: "large", bytes: 102400 }, + { label: "xlarge", bytes: 1048576 }, +]; + +const DRIP_PROFILES = [ + { label: "safe", dripDuration: 30, dripDelay: 0, dripBytes: 100 }, + { label: "boundary", dripDuration: 55, dripDelay: 0, dripBytes: 100 }, + { label: "over", dripDuration: 65, dripDelay: 0, dripBytes: 100 }, + { label: "slow-start", dripDuration: 50, dripDelay: 10, dripBytes: 100 }, +]; + +function generateStressRequests(count, mode) { + const requests = []; + if (mode === "uniform") { + const baseUrl = document.getElementById("stress-test-url").value; + for (let i = 1; i <= count; i++) { + requests.push({ id: i, url: `${baseUrl}${i}`, label: "uniform", bytes: null }); + } + } else if (mode === "drip") { + for (let i = 1; i <= count; i++) { + const p = DRIP_PROFILES[Math.floor(Math.random() * DRIP_PROFILES.length)]; + requests.push({ + id: i, + url: `https://httpbin.org/drip?duration=${p.dripDuration}&numbytes=${p.dripBytes}&delay=${p.dripDelay}&code=200`, + label: p.label, + bytes: p.dripBytes, + }); + } + } else { + for (let i = 1; i <= count; i++) { + const p = STRESS_PROFILES[Math.floor(Math.random() * STRESS_PROFILES.length)]; + requests.push({ + id: i, + url: `https://httpbin.org/bytes/${p.bytes}`, + label: p.label, + bytes: p.bytes, + }); + } + } + return requests; +} + +let stressTest = null; + +// Per-profile live tracker state +let trackerData = {}; + +function initStressTracker(requests) { + const tracker = document.getElementById("stress-tracker"); + tracker.style.display = "block"; + trackerData = {}; + + // Count how many of each profile were sent + for (const req of requests) { + if (!trackerData[req.label]) { + trackerData[req.label] = { sent: 0, ok: 0, fail: 0, times: [] }; + } + trackerData[req.label].sent++; + } + + renderTracker(); +} + +function renderTracker() { + const tracker = document.getElementById("stress-tracker"); + const rows = Object.entries(trackerData).map(([label, d]) => { + const pending = d.sent - d.ok - d.fail; + const avg = d.times.length > 0 + ? (d.times.reduce((a, b) => a + b, 0) / d.times.length).toFixed(1) + "s" + : "-"; + const max = d.times.length > 0 ? Math.max(...d.times).toFixed(1) + "s" : "-"; + const min = d.times.length > 0 ? Math.min(...d.times).toFixed(1) + "s" : "-"; + + let status = ""; + if (d.ok > 0) status += `${d.ok} ok`; + if (d.fail > 0) status += `${status ? " " : ""}${d.fail} fail`; + if (pending > 0) status += `${status ? " " : ""}${pending} pending`; + + let timing = ""; + if (d.times.length > 0) { + timing = `avg ${avg} / min ${min} / max ${max}`; } - await Promise.all(requests); - displaySend('All 10 concurrent requests dispatched!'); + return ` + ${label} + ${d.sent} + ${status} + ${timing} + `; + }); + + tracker.innerHTML = ` + + + + + + + ${rows.join("")} +
profilesentstatustiming
`; } -async function doPost() { - const url = document.getElementById('post_url').value; - const body = document.getElementById('post_body').value; +function onStressTestFetchResult(result) { + if (!stressTest) return; - displaySend(`[POST] Sending POST request to ${url}`); - displaySend(`[POST] Body: ${body}`); + const profile = stressTest.profiles[result.id]; + result.label = profile ? profile.label : "?"; - await client.doPost(url, body); -} + stressTest.results.push(result); + stressTest.completionOrder.push(result.id); -/** - * Display log messages from MixFetch. Colors based on level. - * - * @param {string} message - * @param {string} level - 'info', 'error', 'warn', or 'debug' - */ -function displayLog(message, level) { - let timestamp = new Date().toISOString().substr(11, 12); + // Update tracker + const td = trackerData[result.label]; + if (td) { + if (result.ok) { + td.ok++; + td.times.push(parseFloat(result.elapsed)); + } else { + td.fail++; + } + renderTracker(); + } - const colors = { - info: 'gray', - error: 'red', - warn: 'orange', - debug: 'purple', - }; + const progress = `${stressTest.results.length}/${stressTest.count}`; + const tag = `#${result.id} ${result.label}`; - let logDiv = document.createElement('div'); - let paragraph = document.createElement('p'); - paragraph.setAttribute('style', `color: ${colors[level] || 'gray'}`); - let paragraphContent = document.createTextNode(timestamp + ' [' + level.toUpperCase() + '] ' + message); - paragraph.appendChild(paragraphContent); + if (result.ok) { + console.log(`%c[${tag}] ${result.status} OK ${result.elapsed}s ${result.textLength}B (${progress})`, "color: green"); + } else { + console.error(`[${tag}] FAIL ${result.elapsed}s ${result.error} (${progress})`); + } - logDiv.appendChild(paragraph); - document.getElementById('output').appendChild(logDiv); -} + updateStatus("stress-test-status", progress, "orange"); -/** - * Display messages that have been sent up the websocket. Colours them blue. - * - * @param {string} message - */ -function displaySend(message) { - let timestamp = new Date().toISOString().substr(11, 12); + // Summary on completion + if (stressTest.results.length === stressTest.count) { + const totalElapsed = ((performance.now() - stressTest.startTime) / 1000).toFixed(2); + const succeeded = stressTest.results.filter((r) => r.ok).length; + const failed = stressTest.results.filter((r) => !r.ok).length; - let sendDiv = document.createElement('div'); - let paragraph = document.createElement('p'); - paragraph.setAttribute('style', 'color: blue'); - let paragraphContent = document.createTextNode(timestamp + ' sent >>> ' + message); - paragraph.appendChild(paragraphContent); + console.log(`%c=== COMPLETE: ${totalElapsed}s | OK ${succeeded}/${stressTest.count} | Failed ${failed}/${stressTest.count} ===`, "font-weight: bold"); + console.log("Completion order:", stressTest.completionOrder); - sendDiv.appendChild(paragraph); - document.getElementById('output').appendChild(sendDiv); -} + // Per-profile breakdown in console + const profileList = stressTest.mode === "drip" ? DRIP_PROFILES : STRESS_PROFILES; + if (stressTest.mode === "mixed" || stressTest.mode === "drip") { + const table = []; + for (const profile of profileList) { + const matching = stressTest.results.filter((r) => r.label === profile.label); + if (matching.length === 0) continue; + const ok = matching.filter((r) => r.ok).length; + const times = matching.filter((r) => r.ok).map((r) => parseFloat(r.elapsed)); + const avg = times.length > 0 ? (times.reduce((a, b) => a + b, 0) / times.length).toFixed(2) : "-"; + const max = times.length > 0 ? Math.max(...times).toFixed(2) : "-"; + table.push({ profile: profile.label, ok: `${ok}/${matching.length}`, avg: `${avg}s`, max: `${max}s` }); + } + console.table(table); + } -function displayReceivedRawString(raw) { - let timestamp = new Date().toISOString().substr(11, 12); - let receivedDiv = document.createElement('div'); - let paragraph = document.createElement('p'); - paragraph.setAttribute('style', 'color: green'); - let paragraphContent = document.createTextNode(timestamp + ' received >>> ' + raw); - paragraph.appendChild(paragraphContent); - receivedDiv.appendChild(paragraph); - document.getElementById('output').appendChild(receivedDiv); + updateStatus("stress-test-status", + `Done: ${succeeded}/${stressTest.count} OK, ${failed} failed (${totalElapsed}s)`, + failed > 0 ? "red" : "green" + ); + document.querySelector("#stress-test-button").disabled = false; + stressTest = null; + } } main(); diff --git a/wasm/mix-fetch/internal-dev/package-lock.json b/wasm/mix-fetch/internal-dev/package-lock.json index d46f8e3e42..430233c08b 100644 --- a/wasm/mix-fetch/internal-dev/package-lock.json +++ b/wasm/mix-fetch/internal-dev/package-lock.json @@ -26,7 +26,7 @@ "../go-mix-conn/build": {}, "../pkg": { "name": "@nymproject/mix-fetch-wasm", - "version": "1.4.2", + "version": "1.4.3", "license": "Apache-2.0" }, "node_modules/@discoveryjs/json-ext": { diff --git a/wasm/mix-fetch/internal-dev/worker.js b/wasm/mix-fetch/internal-dev/worker.js index 3ccc2cb5a6..eaf8a8449e 100644 --- a/wasm/mix-fetch/internal-dev/worker.js +++ b/wasm/mix-fetch/internal-dev/worker.js @@ -206,30 +206,33 @@ async function handleFetchPayload(target) { } } -async function handlePostPayload(url, body) { +async function handleStressTestFetch(id, url, label) { if (!mixFetchReady) { sendLog('MixFetch not ready yet', 'error'); return; } - const args = { - method: 'POST', - mode: "unsafe-ignore-cors", - headers: { - 'Content-Type': 'application/json', - }, - body: body, - }; + const tag = `[stress #${id} ${label}]`; + const start = performance.now(); + const args = { mode: "unsafe-ignore-cors" }; try { - sendLog(`POST request to: ${url}`); - sendLog(`POST body: ${body}`); - const mixFetchRes = await mixFetch(url, args); - sendLog('POST completed'); - await logFetchResult(mixFetchRes); + sendLog(`${tag} Fetching: ${url}`); + const res = await mixFetch(url, args); + const text = await res.text(); + const elapsed = ((performance.now() - start) / 1000).toFixed(2); + sendLog(`${tag} ${res.status} OK in ${elapsed}s (${text.length} bytes)`); + self.postMessage({ + kind: 'StressTestFetchResult', + args: { id, ok: true, status: res.status, elapsed, textLength: text.length, body: text }, + }); } catch (e) { - sendLog('POST request failure: ' + e, 'error'); - console.error("mix fetch POST request failure: ", e); + const elapsed = ((performance.now() - start) / 1000).toFixed(2); + sendLog(`${tag} FAILED in ${elapsed}s: ${e}`, 'error'); + self.postMessage({ + kind: 'StressTestFetchResult', + args: { id, ok: false, elapsed, error: String(e) }, + }); } } @@ -247,9 +250,17 @@ function setupMessageHandler() { await handleFetchPayload(target); break; } - case 'PostPayload': { - const { url, body } = event.data.args; - await handlePostPayload(url, body); + case 'SetGoTimeout': { + const { timeoutMs } = event.data.args; + sendLog(`Setting Go-side request timeout to ${timeoutMs}ms`); + self.__go_rs_bridge__.goWasmSetMixFetchRequestTimeout(timeoutMs); + break; + } + case 'StressTestFetch': { + const { id, url, label } = event.data.args; + // NOT awaited — each request runs independently, + // just like separate callers in a real app + handleStressTestFetch(id, url, label); break; } } diff --git a/wasm/mix-fetch/internal-dev/yarn.lock b/wasm/mix-fetch/internal-dev/yarn.lock index 53c976c49e..686bea659e 100644 --- a/wasm/mix-fetch/internal-dev/yarn.lock +++ b/wasm/mix-fetch/internal-dev/yarn.lock @@ -238,7 +238,7 @@ fastq "^1.6.0" "@nymproject/mix-fetch-wasm@file:../pkg": - version "1.4.2" + version "1.4.3" resolved "file:../pkg" "@peculiar/asn1-cms@^2.6.0", "@peculiar/asn1-cms@^2.6.1":