diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 218e946..71fa008 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -95,6 +95,7 @@ from meshchatx.src.backend.lxmf_utils import (
is_user_facing_lxmf_payload,
lxmf_fields_are_columba_reaction,
lxmf_sidebar_preview_for_conversation_latest_row,
+ validate_app_extensions_for_lxmf_http_send,
)
from meshchatx.src.backend.map_manager import MAX_EXPORT_TILES, TRANSPARENT_TILE
from meshchatx.src.backend.markdown_renderer import MarkdownRenderer
@@ -2334,6 +2335,7 @@ class ReticulumMeshChat:
on_page_download_failure=on_failure,
on_progress_update=on_progress,
timeout=120,
+ reticulum=getattr(self, "reticulum", None),
)
try:
@@ -8044,7 +8046,6 @@ class ReticulumMeshChat:
or "Anonymous Peer"
)
- # get current hops away
hops = RNS.Transport.hops_to(
bytes.fromhex(announce["destination_hash"]),
)
@@ -9618,10 +9619,11 @@ class ReticulumMeshChat:
)
try:
- bot_id = self.bot_handler.start_bot(
+ bot_id = await asyncio.to_thread(
+ self.bot_handler.start_bot,
template_id,
- name=name,
- bot_id=bot_id,
+ name,
+ bot_id,
)
return web.json_response({"bot_id": bot_id, "success": True})
except Exception as e:
@@ -9642,7 +9644,7 @@ class ReticulumMeshChat:
)
try:
- success = self.bot_handler.stop_bot(bot_id)
+ success = await asyncio.to_thread(self.bot_handler.stop_bot, bot_id)
return web.json_response({"success": success})
except Exception as e:
return web.json_response(
@@ -9662,7 +9664,10 @@ class ReticulumMeshChat:
)
try:
- new_id = self.bot_handler.restart_bot(bot_id)
+ new_id = await asyncio.to_thread(
+ self.bot_handler.restart_bot,
+ bot_id,
+ )
return web.json_response({"bot_id": new_id, "success": True})
except Exception as e:
return web.json_response(
@@ -9682,7 +9687,7 @@ class ReticulumMeshChat:
)
try:
- success = self.bot_handler.delete_bot(bot_id)
+ success = await asyncio.to_thread(self.bot_handler.delete_bot, bot_id)
return web.json_response({"success": success})
except Exception as e:
return web.json_response(
@@ -9701,7 +9706,10 @@ class ReticulumMeshChat:
)
try:
- result = self.bot_handler.read_subprocess_log(bot_id)
+ result = await asyncio.to_thread(
+ self.bot_handler.read_subprocess_log,
+ bot_id,
+ )
return web.json_response(result)
except ValueError as e:
return web.json_response(
@@ -9727,7 +9735,11 @@ class ReticulumMeshChat:
)
try:
- self.bot_handler.update_bot_name(bot_id, name)
+ await asyncio.to_thread(
+ self.bot_handler.update_bot_name,
+ bot_id,
+ name,
+ )
return web.json_response({"success": True})
except ValueError as e:
return web.json_response(
@@ -9752,7 +9764,7 @@ class ReticulumMeshChat:
)
try:
- self.bot_handler.request_announce(bot_id)
+ await asyncio.to_thread(self.bot_handler.request_announce, bot_id)
return web.json_response({"success": True})
except ValueError as e:
return web.json_response(
@@ -10026,7 +10038,27 @@ class ReticulumMeshChat:
status=400,
)
- fields = lm.get("fields") if isinstance(lm.get("fields"), dict) else {}
+ raw_fields = lm.get("fields")
+ fields = dict(raw_fields) if isinstance(raw_fields, dict) else {}
+ app_extensions_payload = fields.pop("app_extensions", None)
+ validated_app_extensions = None
+ if app_extensions_payload is not None:
+ if not isinstance(app_extensions_payload, dict):
+ return web.json_response(
+ {"message": "Invalid app_extensions"},
+ status=400,
+ )
+ try:
+ validated_app_extensions = (
+ validate_app_extensions_for_lxmf_http_send(
+ app_extensions_payload,
+ )
+ )
+ except ValueError:
+ return web.json_response(
+ {"message": "Invalid app_extensions"},
+ status=400,
+ )
image_field = None
audio_field = None
@@ -10107,6 +10139,7 @@ class ReticulumMeshChat:
delivery_method=delivery_method,
reply_to_hash=reply_to_hash,
reply_quoted_content=reply_quoted_content,
+ app_extensions=validated_app_extensions,
)
return web.json_response(
@@ -10341,9 +10374,28 @@ class ReticulumMeshChat:
@routes.get("/api/v1/lxmf-messages/{message_hash}/uri")
async def lxmf_message_uri(request):
"""Build a reticulum:// URI; prefer the router cache over DB-only state."""
- message_hash = request.match_info.get("message_hash")
+ from meshchatx.src.backend.meshchat_utils import (
+ find_lxm_by_content_hash_for_paper_uri,
+ hex_identifier_to_bytes,
+ lxmf_message_try_paper_uri_string,
+ normalized_meshchat_lxmf_message_hash_hex,
+ )
- lxm = self.message_router.get_message(bytes.fromhex(message_hash))
+ raw_hash = request.match_info.get("message_hash")
+ nh = normalized_meshchat_lxmf_message_hash_hex(raw_hash)
+ if not nh:
+ return web.json_response(
+ {"message": "Invalid message hash"},
+ status=400,
+ )
+ hb = hex_identifier_to_bytes(nh)
+ if hb is None:
+ return web.json_response(
+ {"message": "Invalid message hash"},
+ status=400,
+ )
+
+ lxm = find_lxm_by_content_hash_for_paper_uri(self.message_router, hb)
if not lxm:
return web.json_response(
@@ -10353,16 +10405,16 @@ class ReticulumMeshChat:
status=404,
)
- try:
- # change delivery method to paper so as_uri works
- original_method = lxm.method
- lxm.method = LXMF.LXMessage.PAPER
- uri = lxm.as_uri()
- lxm.method = original_method # restore
+ uri, err_detail = lxmf_message_try_paper_uri_string(lxm)
+ if not uri:
+ body = {
+ "message": "Could not serialize this LXMF payload as a Paper URI"
+ }
+ if err_detail:
+ body["detail"] = err_detail
+ return web.json_response(body, status=422)
- return web.json_response({"uri": uri})
- except Exception as e:
- return web.json_response({"message": str(e)}, status=500)
+ return web.json_response({"uri": uri})
# delete lxmf messages for conversation
@routes.delete("/api/v1/lxmf-messages/conversation/{destination_hash}")
@@ -12920,6 +12972,11 @@ class ReticulumMeshChat:
self._parse_bool(data["nomad_render_plaintext_enabled"]),
)
+ if "nomad_micron_wasm_enabled" in data:
+ self.config.nomad_micron_wasm_enabled.set(
+ self._parse_bool(data["nomad_micron_wasm_enabled"]),
+ )
+
if "nomad_default_page_path" in data:
from meshchatx.src.backend.page_node import is_allowed_page_filename
@@ -13601,24 +13658,23 @@ class ReticulumMeshChat:
on_file_download_failure,
on_file_download_progress,
on_phase=on_file_download_phase,
+ reticulum=getattr(self, "reticulum", None),
)
downloader.start_time = time.time()
self.active_downloads[download_id] = downloader
- # notify client download started
- AsyncUtils.run_async(
- client.send_str(
- json.dumps(
- {
- "type": "nomadnet.file.download",
- "download_id": download_id,
- "nomadnet_file_download": {
- "status": "started",
- "destination_hash": destination_hash.hex(),
- "file_path": file_path,
- },
+ # notify client download started (await so phase updates cannot reorder ahead of started)
+ await client.send_str(
+ json.dumps(
+ {
+ "type": "nomadnet.file.download",
+ "download_id": download_id,
+ "nomadnet_file_download": {
+ "status": "started",
+ "destination_hash": destination_hash.hex(),
+ "file_path": file_path,
},
- ),
+ },
),
)
@@ -13795,23 +13851,22 @@ class ReticulumMeshChat:
on_page_download_failure,
on_page_download_progress,
on_phase=on_page_download_phase,
+ reticulum=getattr(self, "reticulum", None),
)
self.active_downloads[download_id] = downloader
- # notify client download started
- AsyncUtils.run_async(
- client.send_str(
- json.dumps(
- {
- "type": "nomadnet.page.download",
- "download_id": download_id,
- "nomadnet_page_download": {
- "status": "started",
- "destination_hash": destination_hash.hex(),
- "page_path": page_path,
- },
+ # notify client download started (await so phase updates cannot reorder ahead of started)
+ await client.send_str(
+ json.dumps(
+ {
+ "type": "nomadnet.page.download",
+ "download_id": download_id,
+ "nomadnet_page_download": {
+ "status": "started",
+ "destination_hash": destination_hash.hex(),
+ "page_path": page_path,
},
- ),
+ },
),
)
@@ -14402,6 +14457,7 @@ class ReticulumMeshChat:
"nomad_render_markdown_enabled": ctx.config.nomad_render_markdown_enabled.get(),
"nomad_render_html_enabled": ctx.config.nomad_render_html_enabled.get(),
"nomad_render_plaintext_enabled": ctx.config.nomad_render_plaintext_enabled.get(),
+ "nomad_micron_wasm_enabled": ctx.config.nomad_micron_wasm_enabled.get(),
"nomad_default_page_path": ctx.config.nomad_default_page_path.get(),
"local_message_auto_delete_enabled": ctx.config.local_message_auto_delete_enabled.get(),
"local_message_auto_delete_value": ctx.config.local_message_auto_delete_value.get(),
@@ -15762,7 +15818,6 @@ class ReticulumMeshChat:
# We cannot replay "old" paths from the app layer — Transport.request_path refreshes discovery.
path_outcome = await self._await_transport_path(destination_hash_bytes)
- # find destination identity from hash
destination_identity = RNS.Identity.recall(destination_hash_bytes)
if destination_identity is None:
# we have to bail out of sending, since we don't have the identity/path yet
diff --git a/meshchatx/src/backend/config_manager.py b/meshchatx/src/backend/config_manager.py
index a17e423..b3dcd7f 100644
--- a/meshchatx/src/backend/config_manager.py
+++ b/meshchatx/src/backend/config_manager.py
@@ -460,6 +460,11 @@ class ConfigManager:
"nomad_render_plaintext_enabled",
True,
)
+ self.nomad_micron_wasm_enabled = self.BoolConfig(
+ self,
+ "nomad_micron_wasm_enabled",
+ True,
+ )
self.nomad_default_page_path = self.StringConfig(
self,
"nomad_default_page_path",
diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
index bce79d1..89d9558 100644
--- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
+++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
@@ -95,6 +95,62 @@
+
DOMPurify is not installed or sanitization failed.
`; + } + } + + /** + * Split Micron source into blocks for WASM conversion, keeping MeshChat partial-include lines on the JS path. + */ + static splitMicronMarkupWasmSegments(markup) { + if (markup == null) { + return []; + } + const lines = String(markup).split("\n"); + const segments = []; + let buf = []; + for (const line of lines) { + const trimmed = line.trim(); + if (MicronParser.PARTIAL_LINE_REGEX.test(trimmed)) { + if (buf.length) { + segments.push({ type: "mu", text: buf.join("\n") }); + buf = []; + } + segments.push({ type: "partial", line }); + } else { + buf.push(line); + } + } + if (buf.length) { + segments.push({ type: "mu", text: buf.join("\n") }); + } + return segments; + } + injectMonospaceStyles() { if (document.getElementById("micron-monospace-styles")) { return; @@ -156,10 +209,23 @@ export default class MicronParser extends BaseMicronParser { document.head.appendChild(styleEl); } - convertMicronToHtml(markup, partialContents = {}) { - if (markup == null) return ""; - if (typeof markup !== "string") markup = String(markup); + convertMicronToHtmlWasmHybrid(markup, partialContents = {}) { + const mc = globalThis.micronConvert; + const segments = MicronParser.splitMicronMarkupWasmSegments(markup); + let html = ""; + for (const seg of segments) { + if (seg.type === "mu") { + html += MicronParser.sanitizeRenderedMicronHtml( + mc(seg.text, this.darkTheme, this.enableForceMonospace) + ); + } else { + html += this._convertMicronToHtmlJs(seg.line + "\n", partialContents); + } + } + return html; + } + _convertMicronToHtmlJs(markup, partialContents = {}) { const build = () => { let html = ""; @@ -230,24 +296,7 @@ export default class MicronParser extends BaseMicronParser { } } - try { - const sanitized = DOMPurify.sanitize(html, { - USE_PROFILES: { html: true }, - ALLOWED_URI_REGEXP, - }); - try { - return MicronParser.stripOverlayStyles(sanitized); - } catch (e) { - console.warn("MicronParser: stripOverlayStyles failed", e); - return sanitized; - } - } catch (error) { - console.warn( - "DOMPurify is not installed or sanitization failed. Include dompurify or check the build.", - error - ); - return `DOMPurify is not installed or sanitization failed.
`; - } + return MicronParser.sanitizeRenderedMicronHtml(html); }; try { @@ -269,6 +318,22 @@ export default class MicronParser extends BaseMicronParser { } } + convertMicronToHtml(markup, partialContents = {}, options = {}) { + if (markup == null) return ""; + if (typeof markup !== "string") markup = String(markup); + + const wantWasm = options.useWasm === true && typeof globalThis.micronConvert === "function"; + if (wantWasm) { + try { + return this.convertMicronToHtmlWasmHybrid(markup, partialContents); + } catch (e) { + console.warn("MicronParser: WASM Micron conversion failed, using JS parser", e); + } + } + + return this._convertMicronToHtmlJs(markup, partialContents); + } + convertMicronToFragment(markup) { if (markup == null) { return document.createDocumentFragment(); diff --git a/meshchatx/src/frontend/js/MicronWasmLoader.js b/meshchatx/src/frontend/js/MicronWasmLoader.js new file mode 100644 index 0000000..627722f --- /dev/null +++ b/meshchatx/src/frontend/js/MicronWasmLoader.js @@ -0,0 +1,94 @@ +/** + * Lazy-load micron-parser-go WASM (see https://github.com/Quad4-Software/Micron-Parser-Go ). + * Requires wasm_exec.js from Go and micron-parser-go.wasm under /vendor/micron-parser-go/. + * Files are fetched at production build time (scripts/fetch-micron-wasm.mjs); omitted builds set VITE_MICRON_WASM_BUNDLED=false. + */ + +let resolvedPromise = null; + +/** True when WASM artifacts were present at Vite build time (not runtime probing). */ +export function isMicronWasmBundled() { + if (typeof globalThis !== "undefined" && typeof globalThis.__MESHCHATX_TEST_MICRON_WASM_BUNDLED__ === "boolean") { + return globalThis.__MESHCHATX_TEST_MICRON_WASM_BUNDLED__; + } + return import.meta.env.VITE_MICRON_WASM_BUNDLED === "true"; +} + +function baseUrl() { + const root = import.meta.env.BASE_URL || "/"; + return `${root.replace(/\/?$/, "/")}vendor/micron-parser-go`; +} + +function injectScript(src) { + const id = "meshchatx-micron-wasm-exec"; + if (document.getElementById(id)) { + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + const s = document.createElement("script"); + s.id = id; + s.async = true; + s.src = src; + s.onload = () => resolve(); + s.onerror = () => reject(new Error(`Micron WASM: failed to load script ${src}`)); + document.head.appendChild(s); + }); +} + +async function instantiateOnce() { + if (typeof WebAssembly === "undefined") { + throw new Error("Micron WASM: WebAssembly is not available"); + } + const root = baseUrl(); + await injectScript(`${root}/wasm_exec.js`); + if (typeof globalThis.Go === "undefined") { + throw new Error("Micron WASM: Go runtime missing after wasm_exec.js load"); + } + const wasmUrl = `${root}/micron-parser-go.wasm`; + const go = new globalThis.Go(); + let result; + try { + result = await WebAssembly.instantiateStreaming(fetch(wasmUrl), go.importObject); + } catch { + const buf = await fetch(wasmUrl).then((r) => { + if (!r.ok) { + throw new Error(`Micron WASM: fetch failed (${r.status})`); + } + return r.arrayBuffer(); + }); + result = await WebAssembly.instantiate(buf, go.importObject); + } + go.run(result.instance); + if (typeof globalThis.micronConvert !== "function") { + throw new Error("Micron WASM: micronConvert was not registered"); + } +} + +export function invalidateNomadMicronWasmPreload() { + resolvedPromise = null; +} + +/** + * Ensures micron-parser-go WASM is initialized; resolves true when micronConvert is callable. + */ +export function preloadNomadMicronWasm() { + if (!isMicronWasmBundled()) { + return Promise.resolve(false); + } + if (typeof globalThis.micronConvert === "function") { + return Promise.resolve(true); + } + if (resolvedPromise === null) { + resolvedPromise = (async () => { + try { + await instantiateOnce(); + return typeof globalThis.micronConvert === "function"; + } catch (e) { + console.warn(e); + resolvedPromise = null; + return false; + } + })(); + } + return resolvedPromise; +} diff --git a/meshchatx/src/frontend/js/NomadPageRenderer.js b/meshchatx/src/frontend/js/NomadPageRenderer.js index 8c282e0..11d8f36 100644 --- a/meshchatx/src/frontend/js/NomadPageRenderer.js +++ b/meshchatx/src/frontend/js/NomadPageRenderer.js @@ -286,10 +286,13 @@ export function renderNomadPageByPath( const renderPlaintext = renderOptions.renderPlaintext !== false; const nomadDestinationHash = renderOptions.nomadDestinationHash || null; const linkOpts = { destinationHash: nomadDestinationHash }; + const micronOpts = { + useWasm: renderOptions.nomad_micron_wasm_use === true && typeof globalThis.micronConvert === "function", + }; const p = (pagePathWithoutData || "").toLowerCase(); if (p.endsWith(".mu")) { const muParser = new MicronParserClass(); - let out = muParser.convertMicronToHtml(content, pagePartials); + let out = muParser.convertMicronToHtml(content, pagePartials, micronOpts); if (nomadDestinationHash) { out = isolateNomadLinksInHtml(out, nomadDestinationHash); } diff --git a/package.json b/package.json index f68a09b..b5e42fa 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "scripts": { "dev": "vite dev", "watch": "pnpm run build-frontend -- --watch", + "prebuild-frontend": "node scripts/fetch-micron-wasm.mjs", "build-frontend": "vite build", "build-backend": "node scripts/build-backend.js", "build-docs": "python3 scripts/build/fetch_reticulum_manual.py", diff --git a/scripts/fetch-micron-wasm.mjs b/scripts/fetch-micron-wasm.mjs new file mode 100644 index 0000000..48978da --- /dev/null +++ b/scripts/fetch-micron-wasm.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +/** + * Downloads micron-parser-go WASM release assets and matching wasm_exec.js for Vite public/. + * Safe to run offline: exits 0 without files when MICRON_WASM_SKIP=1 or network fails. + * + * Override URLs: + * MICRON_PARSER_GO_WASM_URL + * MICRON_GO_WASM_EXEC_URL + */ +import fs from "fs"; +import path from "path"; +import { MICRON_PARSER_GO_RELEASE_TAG } from "./micron-parser-go-version.mjs"; +import { micronWasmVendorPaths, micronWasmRepoRoot } from "./micron-wasm-resolve-bundled.mjs"; + +const DEFAULT_WASM_URL = `https://github.com/Quad4-Software/Micron-Parser-Go/releases/download/${MICRON_PARSER_GO_RELEASE_TAG}/micron-parser-go.wasm`; +const DEFAULT_WASM_EXEC_URL = "https://raw.githubusercontent.com/golang/go/go1.26.2/lib/wasm/wasm_exec.js"; + +const TIMEOUT_MS = Number(process.env.MICRON_WASM_FETCH_TIMEOUT_MS || 120000); + +function rmQuiet(p) { + try { + fs.rmSync(p, { force: true }); + } catch { + /* ignore */ + } +} + +async function fetchBinary(url, destFile) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const res = await fetch(url, { signal: ctrl.signal }); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + const buf = Buffer.from(await res.arrayBuffer()); + fs.mkdirSync(path.dirname(destFile), { recursive: true }); + fs.writeFileSync(destFile, buf); + return buf.length; + } finally { + clearTimeout(t); + } +} + +async function main() { + if (process.env.MICRON_WASM_SKIP === "1") { + console.log("fetch-micron-wasm: MICRON_WASM_SKIP=1, skipping."); + process.exit(0); + } + + const root = micronWasmRepoRoot(); + const { dir, wasm, wasmExec } = micronWasmVendorPaths(root); + const wasmUrl = process.env.MICRON_PARSER_GO_WASM_URL || DEFAULT_WASM_URL; + const execUrl = process.env.MICRON_GO_WASM_EXEC_URL || DEFAULT_WASM_EXEC_URL; + + fs.mkdirSync(dir, { recursive: true }); + + try { + console.log("fetch-micron-wasm: downloading wasm_exec.js..."); + await fetchBinary(execUrl, wasmExec); + console.log("fetch-micron-wasm: downloading micron-parser-go.wasm..."); + const n = await fetchBinary(wasmUrl, wasm); + console.log(`fetch-micron-wasm: OK (${n} bytes WASM)`); + } catch (e) { + console.warn("fetch-micron-wasm: failed:", e?.message || e); + rmQuiet(wasm); + rmQuiet(wasmExec); + process.exit(0); + } +} + +main(); diff --git a/scripts/micron-parser-go-version.mjs b/scripts/micron-parser-go-version.mjs new file mode 100644 index 0000000..2043fe9 --- /dev/null +++ b/scripts/micron-parser-go-version.mjs @@ -0,0 +1,5 @@ +/** + * Micron-Parser-Go release tag for default WASM URL (scripts/fetch-micron-wasm.mjs) + * and build-time tooltip copy (VITE_MICRON_PARSER_GO_RELEASE). + */ +export const MICRON_PARSER_GO_RELEASE_TAG = "v1.0.2"; diff --git a/scripts/micron-wasm-resolve-bundled.mjs b/scripts/micron-wasm-resolve-bundled.mjs new file mode 100644 index 0000000..31dc06f --- /dev/null +++ b/scripts/micron-wasm-resolve-bundled.mjs @@ -0,0 +1,39 @@ +/** + * Resolve paths and detect whether micron-parser-go WASM artifacts are present for Vite/Vitest. + */ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +/** Repository root (parent of scripts/). */ +export function micronWasmRepoRoot() { + return path.join(__dirname, ".."); +} + +export function micronWasmVendorPaths(repoRoot = micronWasmRepoRoot()) { + const dir = path.join(repoRoot, "meshchatx", "src", "frontend", "public", "vendor", "micron-parser-go"); + return { + dir, + wasm: path.join(dir, "micron-parser-go.wasm"), + wasmExec: path.join(dir, "wasm_exec.js"), + }; +} + +/** + * True when both wasm_exec.js and a minimally-sized WASM binary exist (build-time fetch succeeded). + */ +export function isMicronWasmBundled(repoRoot = micronWasmRepoRoot()) { + const { wasm, wasmExec } = micronWasmVendorPaths(repoRoot); + try { + if (!fs.existsSync(wasm) || !fs.existsSync(wasmExec)) { + return false; + } + const wasmStat = fs.statSync(wasm); + const execStat = fs.statSync(wasmExec); + return wasmStat.size >= 8192 && execStat.size >= 1024; + } catch { + return false; + } +} diff --git a/tests/frontend/MicronParser.test.js b/tests/frontend/MicronParser.test.js index 2dd6460..4bcb0d0 100644 --- a/tests/frontend/MicronParser.test.js +++ b/tests/frontend/MicronParser.test.js @@ -707,4 +707,43 @@ describe("MicronParser.js", () => { expect(frag.childNodes.length).toBeGreaterThan(0); }); }); + + describe("micron-parser-go WASM integration helpers", () => { + afterEach(() => { + delete globalThis.micronConvert; + }); + + it("splitMicronMarkupWasmSegments isolates MeshChat partial lines", () => { + const dest = "a".repeat(32); + const partialLine = `\`{${dest}:/page/partial.mu}`; + const segments = MicronParser.splitMicronMarkupWasmSegments(`> hello\nworld\n${partialLine}\ntrailer`); + expect(segments).toEqual([ + { type: "mu", text: "> hello\nworld" }, + { type: "partial", line: partialLine }, + { type: "mu", text: "trailer" }, + ]); + }); + + it("convertMicronToHtml falls back to JS when WASM throws", () => { + globalThis.micronConvert = vi.fn(() => { + throw new Error("forced wasm failure"); + }); + const p = new MicronParser(true, false); + const html = p.convertMicronToHtml("`!ok`!", {}, { useWasm: true }); + expect(html.length).toBeGreaterThan(0); + expect(html.toLowerCase()).toContain("ok"); + }); + + it("convertMicronToHtml stitches WASM segments with JS partial lines", () => { + globalThis.micronConvert = vi.fn(() => 'wasm-body
'); + const dest = "b".repeat(32); + const partialLine = `\`{${dest}:/page/inc.mu}`; + const p = new MicronParser(true, false); + const html = p.convertMicronToHtml(`intro line\n${partialLine}`, {}, { useWasm: true }); + expect(globalThis.micronConvert).toHaveBeenCalled(); + expect(html).toContain("wasm-body"); + expect(html).toContain("mu-partial"); + expect(html).toContain("data-dest"); + }); + }); }); diff --git a/tests/frontend/MicronWasmLoader.test.js b/tests/frontend/MicronWasmLoader.test.js new file mode 100644 index 0000000..001ccb4 --- /dev/null +++ b/tests/frontend/MicronWasmLoader.test.js @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + invalidateNomadMicronWasmPreload, + isMicronWasmBundled, + preloadNomadMicronWasm, +} from "../../meshchatx/src/frontend/js/MicronWasmLoader.js"; + +describe("MicronWasmLoader.js", () => { + let origBundledFlag; + + beforeEach(() => { + origBundledFlag = globalThis.__MESHCHATX_TEST_MICRON_WASM_BUNDLED__; + invalidateNomadMicronWasmPreload(); + delete globalThis.micronConvert; + delete globalThis.Go; + document.getElementById("meshchatx-micron-wasm-exec")?.remove(); + }); + + afterEach(() => { + invalidateNomadMicronWasmPreload(); + delete globalThis.micronConvert; + delete globalThis.Go; + document.getElementById("meshchatx-micron-wasm-exec")?.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + if (origBundledFlag === undefined) { + delete globalThis.__MESHCHATX_TEST_MICRON_WASM_BUNDLED__; + } else { + globalThis.__MESHCHATX_TEST_MICRON_WASM_BUNDLED__ = origBundledFlag; + } + }); + + it("isMicronWasmBundled honors __MESHCHATX_TEST_MICRON_WASM_BUNDLED__", () => { + globalThis.__MESHCHATX_TEST_MICRON_WASM_BUNDLED__ = true; + expect(isMicronWasmBundled()).toBe(true); + globalThis.__MESHCHATX_TEST_MICRON_WASM_BUNDLED__ = false; + expect(isMicronWasmBundled()).toBe(false); + }); + + it("preloadNomadMicronWasm resolves false without bundling and does not fetch", async () => { + globalThis.__MESHCHATX_TEST_MICRON_WASM_BUNDLED__ = false; + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response()); + const ok = await preloadNomadMicronWasm(); + expect(ok).toBe(false); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("preloadNomadMicronWasm resolves false when WebAssembly is unavailable", async () => { + globalThis.__MESHCHATX_TEST_MICRON_WASM_BUNDLED__ = true; + vi.stubGlobal("WebAssembly", undefined); + const ok = await preloadNomadMicronWasm(); + expect(ok).toBe(false); + }); + + it("preloadNomadMicronWasm resolves false when wasm_exec script fails to load", async () => { + globalThis.__MESHCHATX_TEST_MICRON_WASM_BUNDLED__ = true; + const appendSpy = vi.spyOn(document.head, "appendChild").mockImplementation((node) => { + if (node?.tagName === "SCRIPT" && typeof node.onerror === "function") { + queueMicrotask(() => node.onerror()); + } + return node; + }); + try { + const ok = await preloadNomadMicronWasm(); + expect(ok).toBe(false); + } finally { + appendSpy.mockRestore(); + } + }); + + it("preloadNomadMicronWasm resolves false when wasm_exec loads but Go is missing", async () => { + globalThis.__MESHCHATX_TEST_MICRON_WASM_BUNDLED__ = true; + const appendSpy = vi.spyOn(document.head, "appendChild").mockImplementation((node) => { + if (node?.tagName === "SCRIPT" && typeof node.onload === "function") { + queueMicrotask(() => node.onload()); + } + return node; + }); + try { + const ok = await preloadNomadMicronWasm(); + expect(ok).toBe(false); + } finally { + appendSpy.mockRestore(); + } + }); + + it("preloadNomadMicronWasm resolves false when WASM instantiation fails", async () => { + globalThis.__MESHCHATX_TEST_MICRON_WASM_BUNDLED__ = true; + globalThis.Go = class { + constructor() { + this.importObject = {}; + this.run = vi.fn(); + } + }; + + const appendSpy = vi.spyOn(document.head, "appendChild").mockImplementation((node) => { + if (node?.tagName === "SCRIPT" && typeof node.onload === "function") { + queueMicrotask(() => node.onload()); + } + return node; + }); + + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + arrayBuffer: async () => new ArrayBuffer(16), + headers: new Headers({ "content-type": "application/wasm" }), + }); + + const streaming = vi + .spyOn(WebAssembly, "instantiateStreaming") + .mockRejectedValue(new Error("streaming failed")); + const instantiate = vi.spyOn(WebAssembly, "instantiate").mockRejectedValue(new Error("bad wasm")); + + try { + const ok = await preloadNomadMicronWasm(); + expect(ok).toBe(false); + expect(streaming).toHaveBeenCalled(); + expect(instantiate).toHaveBeenCalled(); + } finally { + appendSpy.mockRestore(); + } + }); + + it("preloadNomadMicronWasm can retry after invalidateNomadMicronWasmPreload", async () => { + globalThis.__MESHCHATX_TEST_MICRON_WASM_BUNDLED__ = true; + globalThis.Go = class { + constructor() { + this.importObject = {}; + this.run = vi.fn(); + } + }; + + const appendSpy = vi.spyOn(document.head, "appendChild").mockImplementation((node) => { + if (node?.tagName === "SCRIPT" && typeof node.onload === "function") { + queueMicrotask(() => node.onload()); + } + return node; + }); + + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + arrayBuffer: async () => new ArrayBuffer(16), + headers: new Headers({ "content-type": "application/wasm" }), + }); + + vi.spyOn(WebAssembly, "instantiateStreaming").mockRejectedValue(new Error("streaming failed")); + const instantiate = vi + .spyOn(WebAssembly, "instantiate") + .mockRejectedValueOnce(new Error("first")) + .mockRejectedValueOnce(new Error("second")); + + try { + expect(await preloadNomadMicronWasm()).toBe(false); + invalidateNomadMicronWasmPreload(); + expect(await preloadNomadMicronWasm()).toBe(false); + expect(instantiate).toHaveBeenCalledTimes(2); + } finally { + appendSpy.mockRestore(); + } + }); + + it("preloadNomadMicronWasm resolves true when micronConvert is already defined", async () => { + globalThis.__MESHCHATX_TEST_MICRON_WASM_BUNDLED__ = true; + globalThis.micronConvert = vi.fn(() => ""); + expect(await preloadNomadMicronWasm()).toBe(true); + }); +}); diff --git a/tests/frontend/fixtures/settingsPageTestApi.js b/tests/frontend/fixtures/settingsPageTestApi.js index 0e7f66a..a3167b0 100644 --- a/tests/frontend/fixtures/settingsPageTestApi.js +++ b/tests/frontend/fixtures/settingsPageTestApi.js @@ -123,6 +123,7 @@ export function buildFullServerConfig(overrides = {}) { nomad_render_markdown_enabled: true, nomad_render_html_enabled: true, nomad_render_plaintext_enabled: true, + nomad_micron_wasm_enabled: true, nomad_default_page_path: "/page/index.mu", gitea_base_url: "https://git.quad4.io", ...overrides, diff --git a/vite.config.js b/vite.config.js index 20309ab..ceab4aa 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,6 +1,7 @@ import path from "path"; import fs from "fs"; import { defineConfig } from "vite"; +import { MICRON_PARSER_GO_RELEASE_TAG } from "./scripts/micron-parser-go-version.mjs"; import tailwindcss from "@tailwindcss/vite"; import vue from "@vitejs/plugin-vue"; import vuetify from "vite-plugin-vuetify"; @@ -34,9 +35,27 @@ const e2eBackendWs = `ws://127.0.0.1:${e2eBackendPort}`; const appBuildTimeIso = new Date().toISOString(); +function isMicronWasmBundledResolved() { + const wasmDir = path.join(__dirname, "meshchatx", "src", "frontend", "public", "vendor", "micron-parser-go"); + const wasmFile = path.join(wasmDir, "micron-parser-go.wasm"); + const execFile = path.join(wasmDir, "wasm_exec.js"); + try { + if (!fs.existsSync(wasmFile) || !fs.existsSync(execFile)) { + return false; + } + return fs.statSync(wasmFile).size >= 8192 && fs.statSync(execFile).size >= 1024; + } catch { + return false; + } +} + +const micronWasmBundled = isMicronWasmBundledResolved(); + export default defineConfig({ define: { __APP_BUILD_TIME__: JSON.stringify(appBuildTimeIso), + "import.meta.env.VITE_MICRON_WASM_BUNDLED": JSON.stringify(micronWasmBundled ? "true" : "false"), + "import.meta.env.VITE_MICRON_PARSER_GO_RELEASE": JSON.stringify(MICRON_PARSER_GO_RELEASE_TAG), }, plugins: [ tailwindcss(), diff --git a/vitest.config.js b/vitest.config.js index b7591d0..973f6d5 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -1,12 +1,32 @@ import { defineConfig } from "vitest/config"; import vue from "@vitejs/plugin-vue"; import path from "path"; +import fs from "fs"; +import { MICRON_PARSER_GO_RELEASE_TAG } from "./scripts/micron-parser-go-version.mjs"; + +function isMicronWasmBundledResolved(repoRoot) { + const wasmDir = path.join(repoRoot, "meshchatx", "src", "frontend", "public", "vendor", "micron-parser-go"); + const wasmFile = path.join(wasmDir, "micron-parser-go.wasm"); + const execFile = path.join(wasmDir, "wasm_exec.js"); + try { + if (!fs.existsSync(wasmFile) || !fs.existsSync(execFile)) { + return false; + } + return fs.statSync(wasmFile).size >= 8192 && fs.statSync(execFile).size >= 1024; + } catch { + return false; + } +} + +const micronWasmBundled = isMicronWasmBundledResolved(__dirname); const appBuildTimeIso = new Date().toISOString(); export default defineConfig({ define: { __APP_BUILD_TIME__: JSON.stringify(appBuildTimeIso), + "import.meta.env.VITE_MICRON_WASM_BUNDLED": JSON.stringify(micronWasmBundled ? "true" : "false"), + "import.meta.env.VITE_MICRON_PARSER_GO_RELEASE": JSON.stringify(MICRON_PARSER_GO_RELEASE_TAG), }, plugins: [ vue({