mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-05-14 13:15:04 +00:00
feat(micron-wasm): integrate Micron-Parser-Go WASM support and configuration
This commit is contained in:
+104
-49
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -95,6 +95,62 @@
|
||||
</span>
|
||||
</template>
|
||||
</span>
|
||||
<v-tooltip
|
||||
v-if="nomadBrowserRendererChip && !isLoadingNodePage"
|
||||
location="bottom"
|
||||
:open-on-hover="false"
|
||||
:open-on-focus="false"
|
||||
:open-on-click="true"
|
||||
:interactive="true"
|
||||
max-width="320"
|
||||
content-class="!bg-transparent !p-0 shadow-none"
|
||||
>
|
||||
<template #activator="{ props: tooltipActivatorProps }">
|
||||
<span
|
||||
v-bind="tooltipActivatorProps"
|
||||
class="shrink-0 hidden sm:inline-flex sm:items-center max-w-[7.5rem] md:max-w-[9rem] truncate rounded border border-gray-300 bg-gray-50 px-1.5 py-0.5 text-[10px] font-medium leading-tight text-gray-600 cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-900 dark:text-gray-400 dark:focus-visible:ring-blue-400"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
>{{ nomadBrowserRendererChip.label }}</span
|
||||
>
|
||||
</template>
|
||||
<div
|
||||
class="max-w-[min(20rem,85vw)] rounded-lg border border-[var(--mc-border-strong)] bg-[var(--mc-surface)] px-3 py-2 text-xs leading-snug text-[var(--mc-text-secondary)] shadow-lg"
|
||||
>
|
||||
<template v-if="nomadBrowserRendererChip.popoverVariant === 'wasm_active'">
|
||||
<span>{{ $t("nomadnet.renderer_popover_micron_wasm_powered") }}</span>
|
||||
<a
|
||||
class="font-medium text-[var(--mc-text-secondary)] underline underline-offset-2 hover:opacity-90"
|
||||
:href="micronParserGoRepoUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@click.stop
|
||||
>{{ $t("settings.nomad_micron_wasm_link_label") }}</a
|
||||
><span>{{
|
||||
$t("nomadnet.renderer_popover_micron_wasm_active_tail", {
|
||||
version: nomadBrowserRendererChip.micronGoRelease,
|
||||
})
|
||||
}}</span>
|
||||
</template>
|
||||
<template v-else-if="nomadBrowserRendererChip.popoverVariant === 'wasm_pending'">
|
||||
<a
|
||||
class="font-medium text-[var(--mc-text-secondary)] underline underline-offset-2 hover:opacity-90"
|
||||
:href="micronParserGoRepoUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@click.stop
|
||||
>{{ $t("settings.nomad_micron_wasm_link_label") }}</a
|
||||
><span>{{
|
||||
$t("nomadnet.renderer_popover_micron_wasm_pending_tail", {
|
||||
version: nomadBrowserRendererChip.micronGoRelease,
|
||||
})
|
||||
}}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ nomadBrowserRendererChip.tooltipBody }}
|
||||
</template>
|
||||
</div>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
|
||||
<!-- archive button -->
|
||||
@@ -460,6 +516,12 @@ import IconButton from "../IconButton.vue";
|
||||
import DropDownMenu from "../DropDownMenu.vue";
|
||||
import DropDownMenuItem from "../DropDownMenuItem.vue";
|
||||
import GlobalState from "../../js/GlobalState";
|
||||
import {
|
||||
preloadNomadMicronWasm,
|
||||
invalidateNomadMicronWasmPreload,
|
||||
isMicronWasmBundled,
|
||||
} from "../../js/MicronWasmLoader";
|
||||
import { VTooltip } from "vuetify/components/VTooltip";
|
||||
|
||||
export default {
|
||||
name: "NomadNetworkPage",
|
||||
@@ -469,6 +531,7 @@ export default {
|
||||
IconButton,
|
||||
DropDownMenu,
|
||||
DropDownMenuItem,
|
||||
VTooltip,
|
||||
},
|
||||
props: {
|
||||
destinationHash: {
|
||||
@@ -510,6 +573,7 @@ export default {
|
||||
nodePagePathHistory: [],
|
||||
nodePageCache: {},
|
||||
currentPageDownloadId: null,
|
||||
pendingNomadPageCancelWithoutId: false,
|
||||
|
||||
isDownloadingNodeFile: false,
|
||||
nodeFilePath: null,
|
||||
@@ -540,6 +604,8 @@ export default {
|
||||
|
||||
pathfinderInProgress: false,
|
||||
pendingLoadLatestArchive: false,
|
||||
|
||||
nomadMicronWasmReady: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -547,6 +613,19 @@ export default {
|
||||
const p = GlobalState.config?.nomad_default_page_path;
|
||||
return typeof p === "string" && p.startsWith("/page/") ? p : "/page/index.mu";
|
||||
},
|
||||
nomadMicronWasmFeatureEffective() {
|
||||
return isMicronWasmBundled() && (GlobalState.config || {}).nomad_micron_wasm_enabled === true;
|
||||
},
|
||||
micronParserGoRepoUrl() {
|
||||
return "https://git.quad4.io/Go-Libs/micron-parser-go";
|
||||
},
|
||||
nomadMicronWasmActive() {
|
||||
return (
|
||||
this.nomadMicronWasmFeatureEffective &&
|
||||
this.nomadMicronWasmReady === true &&
|
||||
typeof globalThis.micronConvert === "function"
|
||||
);
|
||||
},
|
||||
nomadRenderOptions() {
|
||||
const c = GlobalState.config || {};
|
||||
return {
|
||||
@@ -554,8 +633,63 @@ export default {
|
||||
renderHtml: c.nomad_render_html_enabled !== false,
|
||||
renderPlaintext: c.nomad_render_plaintext_enabled !== false,
|
||||
nomadDestinationHash: this.selectedNode?.destination_hash || null,
|
||||
nomad_micron_wasm_use: this.nomadMicronWasmFeatureEffective && this.nomadMicronWasmReady === true,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Active page renderer label for the toolbar chip (.mu uses Micron JS vs WASM).
|
||||
*/
|
||||
nomadBrowserRendererChip() {
|
||||
if (!this.selectedNode || !this.nodePagePath) {
|
||||
return null;
|
||||
}
|
||||
if (this.isShowingNodePageSource) {
|
||||
return null;
|
||||
}
|
||||
const [p] = this.nodePagePath.split("`");
|
||||
const pathLower = (p || "").toLowerCase();
|
||||
const micronGoRelease =
|
||||
typeof import.meta.env.VITE_MICRON_PARSER_GO_RELEASE === "string" &&
|
||||
import.meta.env.VITE_MICRON_PARSER_GO_RELEASE.trim() !== ""
|
||||
? import.meta.env.VITE_MICRON_PARSER_GO_RELEASE.trim()
|
||||
: "\u2014";
|
||||
const plainChip = (labelKey, detailKey, detailParams) => {
|
||||
const detail = detailKey ? this.$t(detailKey, detailParams ?? {}) : "";
|
||||
return {
|
||||
label: this.$t(labelKey),
|
||||
popoverVariant: null,
|
||||
tooltipBody: detail,
|
||||
};
|
||||
};
|
||||
if (pathLower.endsWith(".mu")) {
|
||||
if (this.nomadMicronWasmActive) {
|
||||
return {
|
||||
label: this.$t("nomadnet.renderer_chip_micron_wasm"),
|
||||
popoverVariant: "wasm_active",
|
||||
micronGoRelease,
|
||||
};
|
||||
}
|
||||
const wasmPreferred = this.nomadMicronWasmFeatureEffective;
|
||||
if (wasmPreferred && !this.nomadMicronWasmReady) {
|
||||
return {
|
||||
label: this.$t("nomadnet.renderer_chip_micron_js"),
|
||||
popoverVariant: "wasm_pending",
|
||||
micronGoRelease,
|
||||
};
|
||||
}
|
||||
return plainChip("nomadnet.renderer_chip_micron_js", "nomadnet.renderer_hint_micron_js");
|
||||
}
|
||||
if (pathLower.endsWith(".md")) {
|
||||
return plainChip("nomadnet.renderer_chip_markdown", "nomadnet.renderer_hint_markdown");
|
||||
}
|
||||
if (pathLower.endsWith(".html")) {
|
||||
return plainChip("nomadnet.renderer_chip_html", "nomadnet.renderer_hint_html");
|
||||
}
|
||||
if (pathLower.endsWith(".txt")) {
|
||||
return plainChip("nomadnet.renderer_chip_plaintext", "nomadnet.renderer_hint_plaintext");
|
||||
}
|
||||
return null;
|
||||
},
|
||||
blockedDestinations() {
|
||||
return GlobalState.blockedDestinations;
|
||||
},
|
||||
@@ -689,6 +823,28 @@ export default {
|
||||
// listen for websocket messages
|
||||
WebSocketConnection.on("message", this.onWebsocketMessage);
|
||||
|
||||
this.$watch(
|
||||
() => GlobalState.config?.nomad_micron_wasm_enabled,
|
||||
async (enabled) => {
|
||||
if (!isMicronWasmBundled()) {
|
||||
this.nomadMicronWasmReady = false;
|
||||
return;
|
||||
}
|
||||
if (!enabled) {
|
||||
this.nomadMicronWasmReady = false;
|
||||
return;
|
||||
}
|
||||
invalidateNomadMicronWasmPreload();
|
||||
this.nomadMicronWasmReady = await preloadNomadMicronWasm();
|
||||
}
|
||||
);
|
||||
|
||||
if (isMicronWasmBundled() && GlobalState.config?.nomad_micron_wasm_enabled === true) {
|
||||
preloadNomadMicronWasm().then((ok) => {
|
||||
this.nomadMicronWasmReady = ok === true;
|
||||
});
|
||||
}
|
||||
|
||||
// load nomadnetwork node if a destination hash was provided on page load
|
||||
if (this.destinationHash) {
|
||||
(async () => {
|
||||
@@ -878,6 +1034,16 @@ export default {
|
||||
|
||||
// handle started status
|
||||
if (nomadnetPageDownload.status === "started") {
|
||||
if (this.pendingNomadPageCancelWithoutId) {
|
||||
this.pendingNomadPageCancelWithoutId = false;
|
||||
WebSocketConnection.send(
|
||||
JSON.stringify({
|
||||
type: "nomadnet.download.cancel",
|
||||
download_id: downloadId,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.currentPageDownloadId = downloadId;
|
||||
this.nodePageLoadPhase = "finding_path";
|
||||
return;
|
||||
@@ -994,8 +1160,9 @@ export default {
|
||||
// clear page download if it matches
|
||||
if (this.currentPageDownloadId === downloadId) {
|
||||
this.currentPageDownloadId = null;
|
||||
this.pendingNomadPageCancelWithoutId = false;
|
||||
this.isLoadingNodePage = false;
|
||||
this.nodePageContent = "Download cancelled";
|
||||
this.nodePageContent = this.$t("nomadnet.page_download_cancelled");
|
||||
}
|
||||
|
||||
// clear file download if it matches
|
||||
@@ -1249,6 +1416,8 @@ export default {
|
||||
// get new sequence for this page load
|
||||
const seq = ++this.nodePageRequestSequence;
|
||||
|
||||
this.pendingNomadPageCancelWithoutId = false;
|
||||
|
||||
// get previous page path
|
||||
const previousNodePagePath = this.nodePagePath;
|
||||
|
||||
@@ -1412,6 +1581,10 @@ export default {
|
||||
this.partialIdsByKey = idsByKey;
|
||||
this.partialRefreshByKey = refreshByKey;
|
||||
|
||||
const micronOpts = {
|
||||
useWasm: this.nomadMicronWasmActive,
|
||||
};
|
||||
|
||||
const muParser = new MicronParser();
|
||||
const updatePartialDom = (html, ids) => {
|
||||
const container = this.$el.querySelector(".nodeContainer");
|
||||
@@ -1433,7 +1606,7 @@ export default {
|
||||
path,
|
||||
fields,
|
||||
(pageContent) => {
|
||||
const html = muParser.convertMicronToHtml(pageContent);
|
||||
const html = muParser.convertMicronToHtml(pageContent, {}, micronOpts);
|
||||
const ids = this.partialIdsByKey[key];
|
||||
if (ids) {
|
||||
updatePartialDom(html, ids);
|
||||
@@ -1449,7 +1622,7 @@ export default {
|
||||
const scheduleRefresh = () => {
|
||||
this.partialRefreshTimers[key] = setTimeout(() => {
|
||||
this.downloadNomadNetPage(dest, path, fields, (content) => {
|
||||
const h = muParser.convertMicronToHtml(content);
|
||||
const h = muParser.convertMicronToHtml(content, {}, micronOpts);
|
||||
const idList = this.partialIdsByKey[key];
|
||||
if (idList) {
|
||||
updatePartialDom(h, idList);
|
||||
@@ -2061,7 +2234,23 @@ export default {
|
||||
download_id: this.currentPageDownloadId,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!this.isLoadingNodePage) {
|
||||
return;
|
||||
}
|
||||
const parsed = this.parseNomadnetworkUrl(this.nodePagePath || "");
|
||||
const dh = parsed?.destination_hash || this.selectedNode?.destination_hash;
|
||||
const pathPart = parsed?.path;
|
||||
if (dh && pathPart) {
|
||||
const key = this.getNomadnetPageDownloadCallbackKey(dh, pathPart);
|
||||
delete this.nomadnetPageDownloadCallbacks[key];
|
||||
}
|
||||
this.pendingNomadPageCancelWithoutId = true;
|
||||
this.nodePageRequestSequence += 1;
|
||||
this.isLoadingNodePage = false;
|
||||
this.nodePageLoadPhase = null;
|
||||
this.nodePageContent = this.$t("nomadnet.page_download_cancelled");
|
||||
},
|
||||
cancelFileDownload() {
|
||||
if (this.currentFileDownloadId !== null) {
|
||||
|
||||
@@ -879,6 +879,28 @@
|
||||
>
|
||||
</span>
|
||||
</label>
|
||||
<label v-if="micronWasmBundledInBuild" class="setting-toggle">
|
||||
<Toggle
|
||||
id="nomad-micron-wasm"
|
||||
v-model="config.nomad_micron_wasm_enabled"
|
||||
@update:model-value="onNomadMicronWasmToggle"
|
||||
/>
|
||||
<span class="setting-toggle__label">
|
||||
<span class="setting-toggle__title">{{
|
||||
$t("settings.nomad_micron_wasm_title")
|
||||
}}</span>
|
||||
<span class="setting-toggle__description">
|
||||
{{ $t("settings.nomad_micron_wasm_desc_before_link") }}
|
||||
<a
|
||||
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 underline underline-offset-2"
|
||||
href="https://git.quad4.io/Go-Libs/micron-parser-go"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{{ $t("settings.nomad_micron_wasm_link_label") }}</a
|
||||
>{{ $t("settings.nomad_micron_wasm_desc_after_link") }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Default page path (no URL path)
|
||||
@@ -2504,6 +2526,7 @@ import {
|
||||
} from "../../js/settings/incomingDeliveryLimit";
|
||||
import { normalizeRetentionValue } from "../../js/localMessageRetention";
|
||||
import { matchesSettingSearch, normalizeSearchString } from "../../js/settingsSearchUtils";
|
||||
import { isMicronWasmBundled } from "../../js/MicronWasmLoader.js";
|
||||
|
||||
export default {
|
||||
name: "SettingsPage",
|
||||
@@ -2584,6 +2607,7 @@ export default {
|
||||
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",
|
||||
local_message_auto_delete_enabled: false,
|
||||
local_message_auto_delete_value: 30,
|
||||
@@ -2728,6 +2752,9 @@ export default {
|
||||
"markdown",
|
||||
"HTML",
|
||||
"plaintext",
|
||||
"WASM",
|
||||
"micron-parser-go",
|
||||
"micron-parser",
|
||||
"index.mu",
|
||||
"index.html",
|
||||
"default page",
|
||||
@@ -2877,6 +2904,9 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
micronWasmBundledInBuild() {
|
||||
return isMicronWasmBundled();
|
||||
},
|
||||
settingsSearchActive() {
|
||||
return normalizeSearchString(this.searchQuery).length > 0;
|
||||
},
|
||||
@@ -3546,6 +3576,20 @@ export default {
|
||||
null
|
||||
);
|
||||
},
|
||||
async onNomadMicronWasmToggle(value) {
|
||||
const prev = this.config.nomad_micron_wasm_enabled;
|
||||
this.config.nomad_micron_wasm_enabled = value;
|
||||
try {
|
||||
const newConfig = await patchServerConfig({ nomad_micron_wasm_enabled: value }, window.api);
|
||||
this.config = newConfig;
|
||||
normalizeConfigColors(this.config);
|
||||
this.syncLxmfTransferLimitInputs();
|
||||
} catch (e) {
|
||||
this.config.nomad_micron_wasm_enabled = prev;
|
||||
ToastUtils.error(this.$t("common.save_failed"));
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
async onNomadDefaultPagePathChange() {
|
||||
await this.updateConfig(
|
||||
{
|
||||
|
||||
@@ -24,6 +24,7 @@ const globalState = reactive({
|
||||
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",
|
||||
ui_transparency: 0,
|
||||
ui_glass_enabled: true,
|
||||
|
||||
@@ -99,6 +99,59 @@ export default class MicronParser extends BaseMicronParser {
|
||||
});
|
||||
}
|
||||
|
||||
static sanitizeRenderedMicronHtml(html) {
|
||||
if (html == null) {
|
||||
return "";
|
||||
}
|
||||
const s = typeof html === "string" ? html : String(html);
|
||||
try {
|
||||
const sanitized = DOMPurify.sanitize(s, {
|
||||
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 `<p style="color: red;">DOMPurify is not installed or sanitization failed.</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 `<p style="color: red;">DOMPurify is not installed or sanitization failed.</p>`;
|
||||
}
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(() => '<p data-wasm="1">wasm-body</p>');
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user