feat(desktop): embed BoosAPI Desktop client as a subproject

Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as
a monorepo subdirectory under desktop/. Source only — node_modules,
dist, Cargo target and the upstream .git history are excluded.

desktop/ keeps its own independent Cargo workspace (desktop/src-tauri
with crates/*); it is not part of the backend boosapi crate, so a
`cargo check` at the metapi root still compiles only the backend. Build
the desktop app from desktop/src-tauri per desktop/README.md.

Note: app icons are still placeholder assets pending the final BoosAPI
logo. The updater signing keys live outside the repo and are not
included.
This commit is contained in:
boos
2026-06-22 11:32:57 +08:00
parent d9c78ecc73
commit e0a5f57793
2305 changed files with 449355 additions and 0 deletions
@@ -0,0 +1,265 @@
export type KaTeXCDNWorkerMode = 'classic' | 'module'
export interface KaTeXCDNWorkerOptions {
/**
* Where to load KaTeX from inside the worker.
* - classic mode: UMD build (used via importScripts)
* - module mode: ESM build (used via dynamic import(url))
*/
katexUrl: string
/**
* Optional mhchem plugin URL to load in the worker (recommended).
* - classic mode: UMD build (importScripts)
* - module mode: ESM build (dynamic import(url))
*/
mhchemUrl?: string
/**
* - classic: widest compatibility, uses importScripts()
* - module: requires { type: 'module' } workers, uses import(url)
*/
mode?: KaTeXCDNWorkerMode
/**
* If set, worker prints verbose logs.
*/
debug?: boolean
/**
* Worker constructor options (name/type/credentials).
* Note: for module mode you should pass { type: 'module' }.
*/
workerOptions?: WorkerOptions
/**
* KaTeX render options used in the worker.
* Keep this minimal and stable for caching and predictable output.
*/
renderOptions?: {
throwOnError?: boolean
output?: string
strict?: string
}
}
export interface KaTeXCDNWorkerHandle {
worker: Worker | null
/**
* Revoke the generated Blob URL. Call this when you no longer need the worker.
* This does not terminate the worker automatically.
*/
dispose: () => void
/**
* The generated worker source code (useful for debugging/tests).
*/
source: string
}
function stringifyForWorker(val: any) {
return JSON.stringify(val)
}
export function buildKaTeXCDNWorkerSource(options: KaTeXCDNWorkerOptions): string {
const mode: KaTeXCDNWorkerMode = options.mode ?? 'classic'
const katexUrl = options.katexUrl
const mhchemUrl = options.mhchemUrl
const renderOptions = {
throwOnError: true,
displayMode: true,
output: 'html',
strict: 'ignore',
...(options.renderOptions || {}),
}
const renderOptionsLiteral = stringifyForWorker(renderOptions)
const katexUrlLiteral = stringifyForWorker(katexUrl)
const mhchemUrlLiteral = mhchemUrl ? stringifyForWorker(mhchemUrl) : '""'
if (mode === 'module') {
return `
let DEBUG = false
let katex = null
let katexLoadError = null
let loadPromise = null
function normalizeKaTeX(mod) {
const resolved = (mod && mod.default) ? mod.default : mod
if (resolved && typeof resolved.renderToString === 'function')
return resolved
return null
}
async function loadKaTeX() {
if (katex || katexLoadError)
return
if (!loadPromise) {
loadPromise = (async () => {
try {
const mod = await import(${katexUrlLiteral})
katex = normalizeKaTeX(mod) || null
const mhchemUrl = ${mhchemUrlLiteral}
if (mhchemUrl) {
try {
await import(mhchemUrl)
}
catch (e) {
// ignore optional mhchem load failures
if (DEBUG)
console.warn('[katex-cdn-worker] failed to load mhchem', e)
}
}
}
catch (e) {
katexLoadError = e
}
})()
}
await loadPromise
}
globalThis.addEventListener('message', async (ev) => {
const data = ev.data || {}
if (data.type === 'init') {
DEBUG = !!data.debug
if (DEBUG)
console.debug('[katex-cdn-worker] debug enabled')
return
}
const id = data.id ?? ''
const content = data.content ?? ''
const displayMode = data.displayMode ?? true
await loadKaTeX()
if (!katex) {
const reason = katexLoadError ? String(katexLoadError?.message || katexLoadError) : 'KaTeX is not available in worker'
globalThis.postMessage({ id, error: reason, content, displayMode })
return
}
try {
const opts = ${renderOptionsLiteral}
const html = katex.renderToString(content, { ...opts, displayMode: !!displayMode })
globalThis.postMessage({ id, html, content, displayMode })
}
catch (err) {
const msg = String(err?.message || err)
globalThis.postMessage({ id, error: msg, content, displayMode })
}
})
`.trimStart()
}
// classic mode (importScripts) worker
return `
let DEBUG = false
let katex = null
let katexLoadError = null
function normalizeKaTeX(val) {
const resolved = (val && val.default) ? val.default : val
if (resolved && typeof resolved.renderToString === 'function')
return resolved
return null
}
function loadKaTeXClassic() {
if (katex || katexLoadError)
return
try {
importScripts(${katexUrlLiteral})
const mhchemUrl = ${mhchemUrlLiteral}
if (mhchemUrl) {
try {
importScripts(mhchemUrl)
}
catch (e) {
// ignore optional mhchem load failures
if (DEBUG)
console.warn('[katex-cdn-worker] failed to load mhchem', e)
}
}
katex = normalizeKaTeX(globalThis.katex)
}
catch (e) {
katexLoadError = e
}
}
loadKaTeXClassic()
globalThis.addEventListener('message', (ev) => {
const data = ev.data || {}
if (data.type === 'init') {
DEBUG = !!data.debug
if (DEBUG)
console.debug('[katex-cdn-worker] debug enabled')
return
}
const id = data.id ?? ''
const content = data.content ?? ''
const displayMode = data.displayMode ?? true
if (!katex && !katexLoadError)
loadKaTeXClassic()
if (!katex) {
const reason = katexLoadError ? String(katexLoadError?.message || katexLoadError) : 'KaTeX is not available in worker'
globalThis.postMessage({ id, error: reason, content, displayMode })
return
}
try {
const opts = ${renderOptionsLiteral}
const html = katex.renderToString(content, { ...opts, displayMode: !!displayMode })
globalThis.postMessage({ id, html, content, displayMode })
}
catch (err) {
const msg = String(err?.message || err)
globalThis.postMessage({ id, error: msg, content, displayMode })
}
})
`.trimStart()
}
export function createKaTeXWorkerFromCDN(options: KaTeXCDNWorkerOptions): KaTeXCDNWorkerHandle {
const source = buildKaTeXCDNWorkerSource(options)
// SSR-safe: allow calling this on the server without throwing
if (typeof Worker === 'undefined' || typeof URL === 'undefined' || typeof Blob === 'undefined') {
return {
worker: null,
dispose: () => {},
source,
}
}
const blob = new Blob([source], { type: 'text/javascript' })
const url = URL.createObjectURL(blob)
let revoked = false
const dispose = () => {
if (revoked)
return
revoked = true
try {
URL.revokeObjectURL(url)
}
catch {
// ignore
}
}
const mode: KaTeXCDNWorkerMode = options.mode ?? 'classic'
const workerOptions = mode === 'module'
? ({ ...(options.workerOptions ?? {}), type: 'module' as const } satisfies WorkerOptions)
: options.workerOptions
const worker = new Worker(url, workerOptions)
if (options.debug) {
try {
;(worker as any).postMessage({ type: 'init', debug: true })
}
catch {
// ignore
}
}
return { worker, dispose, source }
}
@@ -0,0 +1,78 @@
import katex from 'katex'
import 'katex/dist/contrib/mhchem'
interface MessageIn {
// supports two shapes: init messages and render messages
type?: 'init' | 'render'
id?: string
content?: string
displayMode?: boolean
debug?: boolean
}
interface MessageOut {
id: string
html?: string
error?: string
}
let DEBUG = false
; (globalThis as any).addEventListener('message', (ev: MessageEvent<MessageIn>) => {
const data = ev.data || {}
if (data.type === 'init') {
DEBUG = !!data.debug
try {
if (DEBUG)
console.debug('[katexRenderer.worker] debug enabled')
}
catch { }
return
}
const id = data.id ?? ''
const content = data.content ?? ''
const displayMode = data.displayMode ?? true
try {
// note: use console for visibility in DevTools when debugging worker
if (DEBUG)
console.debug('[katexRenderer.worker] render start', { id, displayMode, content })
// renderToString is CPU-bound but doesn't touch the DOM, so it's safe in a worker
const html = katex.renderToString(content, {
throwOnError: true,
displayMode,
output: 'html',
strict: 'ignore',
})
const out: MessageOut & { content: string, displayMode: boolean } = { id, html, content, displayMode }
try {
// send back the generated HTML and original input for caching
; (globalThis as any).postMessage(out)
if (DEBUG)
console.debug('[katexRenderer.worker] render success', { id })
}
catch (postErr) {
console.error('[katexRenderer.worker] failed to postMessage result', postErr)
}
}
catch (err: any) {
const out: MessageOut & { content: string, displayMode: boolean } = { id, error: String(err?.message ?? err), content, displayMode }
try {
; (globalThis as any).postMessage(out)
}
catch (postErr) {
console.error('[katexRenderer.worker] failed to postMessage error', postErr)
}
}
})
// Catch any uncaught errors in the worker and attempt to inform the main thread
; (globalThis as any).addEventListener('error', (ev: ErrorEvent) => {
console.error('[katexRenderer.worker] uncaught error', ev.message, ev.error)
try {
; (globalThis as any).postMessage({ id: '__worker_uncaught__', error: String(ev.message ?? ev.error), content: '', displayMode: true })
}
catch { }
})
@@ -0,0 +1,437 @@
import { isKatexEnabled } from '../components/MathInlineNode/katex'
import { normalizeKaTeXRenderInput } from '../utils/normalizeKaTeXRenderInput'
interface Pending {
resolve: (val: string) => void
reject: (err: any) => void
timeoutId: number
}
let worker: Worker | null = null
let workerInitError: any = null
// runtime debug flag controlled by the main thread
let DEBUG_KATEX_WORKER = false
const pending = new Map<string, Pending>()
// Simple in-memory cache to avoid repeated renders for identical input.
const cache = new Map<string, string>()
const CACHE_MAX = 200
// Limit how many concurrent worker renders can be in-flight.
let MAX_CONCURRENCY = 5
// Waiters that want to be notified when load drops below MAX_CONCURRENCY.
const drainWaiters = new Set<() => void>()
function notifyDrainIfBelowCap() {
if (pending.size < MAX_CONCURRENCY && drainWaiters.size) {
const copy = Array.from(drainWaiters)
drainWaiters.clear()
for (const fn of copy) {
try {
fn()
}
catch {
// ignore
}
}
}
}
// Performance monitoring (optional, dev-only by default)
let perfMonitor: any = null
const isDevEnv = typeof import.meta !== 'undefined' && Boolean((import.meta as any).env?.DEV)
try {
// Only load in development. This guard is statically analyzable,
// so the import below is removed from production builds.
if (typeof window !== 'undefined' && isDevEnv) {
// Use an opaque dynamic import path so bundlers don't emit a chunk in prod builds.
// This runs only in dev; in production the whole block is tree-shaken.
import('../utils/performance-monitor').then((a) => {
perfMonitor = a.perfMonitor
})
}
}
catch {
// Ignore errors
}
/**
* Allow user to inject a Worker instance, e.g. from Vite ?worker import.
*/
export function setKaTeXWorker(w: Worker) {
worker = w
workerInitError = null
// Set up message handler to process worker responses
worker.onmessage = (e: MessageEvent) => {
const { id, html, error } = e.data
const p = pending.get(id)
if (!p)
return
pending.delete(id)
clearTimeout(p.timeoutId)
// notify possible waiters if we freed capacity
notifyDrainIfBelowCap()
if (error) {
p.reject(new Error(error))
}
else {
// Cache the result
const { content, displayMode } = e.data
if (content) {
const cacheKey = `${displayMode ? 'd' : 'i'}:${content}`
cache.set(cacheKey, html)
if (cache.size > CACHE_MAX) {
const firstKey = cache.keys().next().value
cache.delete(firstKey)
}
}
p.resolve(html)
}
}
// Optional: handle worker errors
worker.onerror = (e: ErrorEvent) => {
console.error('[katexWorkerClient] Worker error:', e)
// Reject all pending requests
for (const [_id, p] of pending.entries()) {
clearTimeout(p.timeoutId)
p.reject(new Error(`Worker error: ${e.message}`))
}
pending.clear()
// capacity is effectively zeroed; notify to allow callers to decide
notifyDrainIfBelowCap()
}
}
/**
* Remove the current worker instance (for cleanup or SSR).
*/
export function clearKaTeXWorker() {
if (worker) {
worker.terminate?.()
}
worker = null
workerInitError = null
}
function ensureWorker() {
if (!worker) {
workerInitError = new Error('[katexWorkerClient] No worker instance set. Please inject a Worker via setKaTeXWorker().')
; (workerInitError as any).name = 'WorkerInitError'
; (workerInitError as any).code = 'WORKER_INIT_ERROR'
return null
}
return worker
}
// Allow toggling verbose worker debug logs at runtime. When set, we post an init
// message to an existing worker so the worker can enable logs.
export function setKaTeXWorkerDebug(enabled: boolean) {
DEBUG_KATEX_WORKER = !!enabled
if (worker) {
(worker as any).postMessage({ type: 'init', debug: DEBUG_KATEX_WORKER })
}
}
export async function renderKaTeXInWorker(content: string, displayMode = true, timeout = 2000, signal?: AbortSignal): Promise<string> {
const startTime = performance.now()
const normalizedContent = normalizeKaTeXRenderInput(content)
if (!isKatexEnabled()) {
const err = new Error('KaTeX rendering disabled')
;(err as any).name = 'KaTeXDisabled'
;(err as any).code = 'KATEX_DISABLED'
return Promise.reject(err)
}
if (workerInitError) {
return Promise.reject(workerInitError)
}
// Quick cache hit
const cacheKey = `${displayMode ? 'd' : 'i'}:${normalizedContent}`
const cached = cache.get(cacheKey)
if (cached) {
// Record cache hit performance
if (perfMonitor) {
perfMonitor.recordRender({
type: 'cache-hit',
duration: performance.now() - startTime,
formulaLength: normalizedContent.length,
timestamp: Date.now(),
success: true,
})
}
return Promise.resolve(cached)
}
// If workers are unavailable, reject to let caller fallback
const wk = ensureWorker()
if (!wk) {
return Promise.reject(workerInitError)
}
// Enforce simple concurrency cap: if we already have too many in-flight
// requests, ask caller to handle backpressure (e.g. retry later).
if (pending.size >= MAX_CONCURRENCY) {
const err = new Error('Worker busy')
; (err as any).name = 'WorkerBusy'
; (err as any).code = 'WORKER_BUSY'
; (err as any).busy = true
; (err as any).inFlight = pending.size
; (err as any).max = MAX_CONCURRENCY
if (perfMonitor) {
perfMonitor.recordRender({
type: 'worker',
duration: performance.now() - startTime,
formulaLength: normalizedContent.length,
timestamp: Date.now(),
success: false,
error: 'busy',
})
}
return Promise.reject(err)
}
return new Promise((resolve, reject) => {
if (signal?.aborted) {
// align with DOM abort semantics
const err = new Error('Aborted')
; (err as any).name = 'AbortError'
reject(err)
return
}
const id = Math.random().toString(36).slice(2)
const timeoutId = (globalThis as any).setTimeout(() => {
pending.delete(id)
const err = new Error('Worker render timed out')
; (err as any).name = 'WorkerTimeout'
; (err as any).code = 'WORKER_TIMEOUT'
// Record timeout
if (perfMonitor) {
perfMonitor.recordRender({
type: 'worker',
duration: performance.now() - startTime,
formulaLength: normalizedContent.length,
timestamp: Date.now(),
success: false,
error: 'timeout',
})
}
reject(err)
// a slot freed (this request is no longer pending)
notifyDrainIfBelowCap()
}, timeout)
// Listen for abort to cancel this pending request
const onAbort = () => {
(globalThis as any).clearTimeout(timeoutId)
if (pending.has(id))
pending.delete(id)
const err = new Error('Aborted')
; (err as any).name = 'AbortError'
reject(err)
notifyDrainIfBelowCap()
}
if (signal)
signal.addEventListener('abort', onAbort, { once: true })
// Wrap original resolve/reject to record performance
const originalResolve = resolve
const originalReject = reject
const wrappedResolve = (val: string) => {
if (perfMonitor) {
perfMonitor.recordRender({
type: 'worker',
duration: performance.now() - startTime,
formulaLength: normalizedContent.length,
timestamp: Date.now(),
success: true,
})
}
originalResolve(val)
}
const wrappedReject = (err: any) => {
if (perfMonitor) {
perfMonitor.recordRender({
type: 'worker',
duration: performance.now() - startTime,
formulaLength: normalizedContent.length,
timestamp: Date.now(),
success: false,
error: err?.message || String(err),
})
}
originalReject(err)
}
pending.set(id, { resolve: wrappedResolve, reject: wrappedReject, timeoutId })
wk.postMessage({ id, content: normalizedContent, displayMode })
})
}
// Allow callers (e.g. main-thread fallback renderers) to populate the internal cache
// so that synchronous renders can benefit subsequent worker-based calls.
export function setKaTeXCache(content: string, displayMode = true, html: string) {
const cacheKey = `${displayMode ? 'd' : 'i'}:${normalizeKaTeXRenderInput(content)}`
cache.set(cacheKey, html)
if (cache.size > CACHE_MAX) {
const firstKey = cache.keys().next().value
cache.delete(firstKey)
}
}
/**
* Utilities for clients to inspect/adjust worker load behavior
*/
export function getKaTeXWorkerLoad() {
return { inFlight: pending.size, max: MAX_CONCURRENCY }
}
export function setKaTeXWorkerMaxConcurrency(n: number) {
if (Number.isFinite(n) && n > 0)
MAX_CONCURRENCY = Math.floor(n)
}
export const WORKER_BUSY_CODE = 'WORKER_BUSY'
export function isKaTeXWorkerBusy() {
return pending.size >= MAX_CONCURRENCY
}
export function waitForKaTeXWorkerSlot(timeout = 2000, signal?: AbortSignal): Promise<void> {
if (pending.size < MAX_CONCURRENCY)
return Promise.resolve()
return new Promise((resolve, reject) => {
let settled = false
let timer: any
const onDrain = () => {
if (settled)
return
settled = true
if (timer)
(globalThis as any).clearTimeout(timer)
drainWaiters.delete(onDrain)
resolve()
}
drainWaiters.add(onDrain)
timer = (globalThis as any).setTimeout(() => {
if (settled)
return
settled = true
drainWaiters.delete(onDrain)
const err = new Error('Wait for worker slot timed out')
; (err as any).name = 'WorkerBusyTimeout'
; (err as any).code = 'WORKER_BUSY_TIMEOUT'
reject(err)
}, timeout)
// If capacity appears in the next microtask, resolve quickly
queueMicrotask(() => notifyDrainIfBelowCap())
if (signal) {
const onAbort = () => {
if (settled)
return
settled = true
if (timer)
(globalThis as any).clearTimeout(timer)
drainWaiters.delete(onDrain)
const err = new Error('Aborted')
; (err as any).name = 'AbortError'
reject(err)
}
if (signal.aborted)
onAbort()
else
signal.addEventListener('abort', onAbort, { once: true })
}
})
}
export interface BackpressureOptions {
timeout?: number
waitTimeout?: number
backoffMs?: number
maxRetries?: number
signal?: AbortSignal
}
// Global defaults for backpressure behavior
const defaultBackpressure = {
timeout: 2000,
waitTimeout: 1500,
backoffMs: 30,
maxRetries: 1,
}
const MAX_BACKPRESSURE_RETRIES = 8
export function setKaTeXBackpressureDefaults(opts: Partial<typeof defaultBackpressure>) {
if (opts.timeout != null)
defaultBackpressure.timeout = Math.max(0, Math.floor(opts.timeout))
if (opts.waitTimeout != null)
defaultBackpressure.waitTimeout = Math.max(0, Math.floor(opts.waitTimeout))
if (opts.backoffMs != null)
defaultBackpressure.backoffMs = Math.max(0, Math.floor(opts.backoffMs))
if (opts.maxRetries != null)
defaultBackpressure.maxRetries = Math.max(0, Math.floor(opts.maxRetries))
}
export function getKaTeXBackpressureDefaults() {
return { ...defaultBackpressure }
}
/**
* Convenience wrapper: when worker reports busy, wait for a slot and retry.
* Does not implement fallback-to-main-thread; leave that to the caller.
*/
export async function renderKaTeXWithBackpressure(
content: string,
displayMode = true,
opts: BackpressureOptions = {},
): Promise<string> {
if (!isKatexEnabled()) {
const err = new Error('KaTeX rendering disabled')
;(err as any).name = 'KaTeXDisabled'
;(err as any).code = 'KATEX_DISABLED'
throw err
}
const timeout = opts.timeout ?? defaultBackpressure.timeout
const waitTimeout = opts.waitTimeout ?? defaultBackpressure.waitTimeout
const backoffMs = opts.backoffMs ?? defaultBackpressure.backoffMs
const rawMaxRetries = opts.maxRetries ?? defaultBackpressure.maxRetries
const maxRetries = Number.isFinite(rawMaxRetries)
? Math.max(0, Math.min(Math.floor(rawMaxRetries), MAX_BACKPRESSURE_RETRIES))
: defaultBackpressure.maxRetries
const signal = opts.signal
let attempt = 0
// quick loop to handle busy->wait->retry pattern
for (;;) {
if (signal?.aborted) {
const err = new Error('Aborted')
; (err as any).name = 'AbortError'
throw err
}
try {
return await renderKaTeXInWorker(content, displayMode, timeout, signal)
}
catch (err: any) {
if (err?.code !== WORKER_BUSY_CODE || attempt >= maxRetries) {
throw err
}
attempt++
await waitForKaTeXWorkerSlot(waitTimeout, signal).catch(() => { /* ignore, will retry/throw on next loop */ })
if (signal?.aborted) {
const e = new Error('Aborted')
; (e as any).name = 'AbortError'
throw e
}
if (backoffMs > 0) {
await new Promise(r => (globalThis as any).setTimeout(r, backoffMs * attempt))
}
// continue loop to retry
}
}
}
@@ -0,0 +1,287 @@
export type MermaidCDNWorkerMode = 'classic' | 'module'
export interface MermaidCDNWorkerOptions {
/**
* Where to load mermaid from inside the worker.
* - classic mode: non-module build (used via importScripts)
* - module mode: ESM build (used via dynamic import(url))
*/
mermaidUrl: string
/**
* - classic: widest compatibility, uses importScripts()
* - module: requires { type: 'module' } workers, uses import(url)
*/
mode?: MermaidCDNWorkerMode
/**
* If set, worker prints verbose logs.
*/
debug?: boolean
/**
* Worker constructor options (name/type/credentials).
* Note: for module mode you should pass { type: 'module' }.
*/
workerOptions?: WorkerOptions
/**
* Mermaid initialize options used in the worker.
* This worker is used for parsing only; keep options minimal.
*/
initializeOptions?: Record<string, any>
}
export interface MermaidCDNWorkerHandle {
worker: Worker | null
dispose: () => void
source: string
}
function stringifyForWorker(val: any) {
return JSON.stringify(val)
}
export function buildMermaidCDNWorkerSource(options: MermaidCDNWorkerOptions): string {
const mode: MermaidCDNWorkerMode = options.mode ?? 'module'
const mermaidUrlLiteral = stringifyForWorker(options.mermaidUrl)
const initializeOptions = {
startOnLoad: false,
securityLevel: 'loose',
...(options.initializeOptions || {}),
}
const initLiteral = stringifyForWorker(initializeOptions)
// Worker protocol mirrors src/workers/mermaidParser.worker.ts:
// { id, action: 'canParse'|'findPrefix', payload: { code, theme } }
// -> { id, ok: true, result } | { id, ok: false, error }
const sharedLogic = `
let DEBUG = false
let mermaid = null
let mermaidLoadError = null
function normalizeMermaidModule(mod) {
if (!mod)
return mod
const candidate = (mod && mod.default) ? mod.default : mod
if (candidate && (typeof candidate.render === 'function' || typeof candidate.parse === 'function' || typeof candidate.initialize === 'function'))
return candidate
if (candidate && candidate.mermaidAPI && (typeof candidate.mermaidAPI.render === 'function' || typeof candidate.mermaidAPI.parse === 'function')) {
const api = candidate.mermaidAPI
return {
...candidate,
render: api.render ? api.render.bind(api) : undefined,
parse: api.parse ? api.parse.bind(api) : undefined,
initialize: (opts) => {
if (typeof candidate.initialize === 'function')
return candidate.initialize(opts)
return api.initialize ? api.initialize(opts) : undefined
},
}
}
if (mod && mod.mermaid && typeof mod.mermaid.parse === 'function')
return mod.mermaid
return candidate
}
function applyThemeTo(code, theme) {
const themeValue = theme === 'dark' ? 'dark' : 'default'
const themeConfig = \`%%{init: {"theme": "\${themeValue}"}}%%\\n\`
const trimmed = (code || '').trimStart()
if (trimmed.startsWith('%%{'))
return code
return themeConfig + code
}
function findHeaderIndex(lines) {
const headerRe = /^(?:graph|flowchart|flowchart\\s+tb|flowchart\\s+lr|sequenceDiagram|gantt|classDiagram|stateDiagram(?:-v2)?|erDiagram|journey|pie|quadrantChart|timeline|xychart(?:-beta)?)\\b/
for (let i = 0; i < lines.length; i++) {
const l = (lines[i] || '').trim()
if (!l)
continue
if (l.startsWith('%%'))
continue
if (headerRe.test(l))
return i
}
return -1
}
async function canParse(code, theme) {
const themed = applyThemeTo(code, theme)
const anyMermaid = mermaid
if (anyMermaid && typeof anyMermaid.parse === 'function') {
await anyMermaid.parse(themed)
return true
}
throw new Error('mermaid.parse not available in worker')
}
async function findLastRenderablePrefix(baseCode, theme) {
const lines = String(baseCode || '').split('\\n')
const headerIdx = findHeaderIndex(lines)
if (headerIdx === -1)
return null
const head = lines.slice(0, headerIdx + 1)
await canParse(head.join('\\n'), theme)
let low = headerIdx + 1
let high = lines.length
let lastGood = headerIdx + 1
let tries = 0
const MAX_TRIES = 12
while (low <= high && tries < MAX_TRIES) {
const mid = Math.floor((low + high) / 2)
const candidate = [...head, ...lines.slice(headerIdx + 1, mid)].join('\\n')
tries++
try {
await canParse(candidate, theme)
lastGood = mid
low = mid + 1
}
catch {
high = mid - 1
}
}
return [...head, ...lines.slice(headerIdx + 1, lastGood)].join('\\n')
}
function initMermaidOnce() {
if (!mermaid)
return
try {
if (typeof mermaid.initialize === 'function')
mermaid.initialize(${initLiteral})
}
catch (e) {
if (DEBUG)
console.warn('[mermaid-cdn-worker] initialize failed', e)
}
}
globalThis.addEventListener('message', async (ev) => {
const msg = ev.data || {}
if (msg.type === 'init') {
DEBUG = !!msg.debug
if (DEBUG)
console.debug('[mermaid-cdn-worker] debug enabled')
return
}
const id = msg.id
const action = msg.action
const payload = msg.payload || {}
if (!mermaid) {
const errMsg = mermaidLoadError ? String(mermaidLoadError?.message || mermaidLoadError) : 'Mermaid is not available in worker'
globalThis.postMessage({ id, ok: false, error: errMsg })
return
}
try {
if (action === 'canParse') {
const ok = await canParse(payload.code, payload.theme)
globalThis.postMessage({ id, ok: true, result: ok })
return
}
if (action === 'findPrefix') {
const res = await findLastRenderablePrefix(payload.code, payload.theme)
globalThis.postMessage({ id, ok: true, result: res })
return
}
globalThis.postMessage({ id, ok: false, error: 'Unknown action' })
}
catch (e) {
globalThis.postMessage({ id, ok: false, error: String(e?.message || e) })
}
})
`.trimStart()
if (mode === 'module') {
return `
${sharedLogic}
let loadPromise = null
async function loadMermaid() {
if (mermaid || mermaidLoadError)
return
if (!loadPromise) {
loadPromise = (async () => {
try {
const mod = await import(${mermaidUrlLiteral})
mermaid = normalizeMermaidModule(mod) || null
initMermaidOnce()
}
catch (e) {
mermaidLoadError = e
}
})()
}
await loadPromise
}
// Load immediately; failures are reported per-request
await loadMermaid()
`.trimStart()
}
// classic mode: importScripts (requires non-module build)
return `
${sharedLogic}
function loadMermaidClassic() {
if (mermaid || mermaidLoadError)
return
try {
importScripts(${mermaidUrlLiteral})
mermaid = normalizeMermaidModule(globalThis.mermaid) || null
initMermaidOnce()
}
catch (e) {
mermaidLoadError = e
}
}
loadMermaidClassic()
`.trimStart()
}
export function createMermaidWorkerFromCDN(options: MermaidCDNWorkerOptions): MermaidCDNWorkerHandle {
const source = buildMermaidCDNWorkerSource(options)
if (typeof Worker === 'undefined' || typeof URL === 'undefined' || typeof Blob === 'undefined') {
return {
worker: null,
dispose: () => {},
source,
}
}
const blob = new Blob([source], { type: 'text/javascript' })
const url = URL.createObjectURL(blob)
let revoked = false
const dispose = () => {
if (revoked)
return
revoked = true
try {
URL.revokeObjectURL(url)
}
catch {}
}
const mode: MermaidCDNWorkerMode = options.mode ?? 'module'
const workerOptions = mode === 'module'
? ({ ...(options.workerOptions ?? {}), type: 'module' as const } satisfies WorkerOptions)
: options.workerOptions
const worker = new Worker(url, workerOptions)
if (options.debug) {
try {
;(worker as any).postMessage({ type: 'init', debug: true })
}
catch {}
}
return { worker, dispose, source }
}
@@ -0,0 +1,118 @@
/// <reference lib="webworker" />
// Note: types may not be available in worker TS context in some setups; a local shim is provided.
import mermaid from 'mermaid'
declare const self: DedicatedWorkerGlobalScope
// Initialize mermaid in worker for parsing only
mermaid.initialize({ startOnLoad: false, securityLevel: 'loose' })
type Theme = 'light' | 'dark'
interface Req {
id: string
action: 'canParse' | 'findPrefix' | (string & {})
payload: { code: string, theme: Theme }
}
type Res
= | { id: string, ok: true, result: any }
| { id: string, ok: false, error: string }
function applyThemeTo(code: string, theme: Theme) {
const themeValue = theme === 'dark' ? 'dark' : 'default'
const themeConfig = `%%{init: {"theme": "${themeValue}"}}%%\n`
const trimmed = code.trimStart()
if (trimmed.startsWith('%%{'))
return code
return themeConfig + code
}
function findHeaderIndex(lines: string[]) {
const headerRe
= /^(?:graph|flowchart|flowchart\s+tb|flowchart\s+lr|sequenceDiagram|gantt|classDiagram|stateDiagram(?:-v2)?|erDiagram|journey|pie|quadrantChart|timeline|xychart(?:-beta)?)\b/
for (let i = 0; i < lines.length; i++) {
const l = lines[i].trim()
if (!l)
continue
if (l.startsWith('%%'))
continue
if (headerRe.test(l))
return i
}
return -1
}
async function canParse(code: string, theme: Theme) {
const themed = applyThemeTo(code, theme)
const anyMermaid = mermaid as any
if (typeof anyMermaid.parse === 'function') {
await anyMermaid.parse?.(themed)
return true
}
// If parse is unavailable in this mermaid version, signal fallback
throw new Error('mermaid.parse not available in worker')
}
async function findLastRenderablePrefix(baseCode: string, theme: Theme) {
const lines = baseCode.split('\n')
const headerIdx = findHeaderIndex(lines)
if (headerIdx === -1)
return null
const head = lines.slice(0, headerIdx + 1)
// Validate header alone first
await canParse(head.join('\n'), theme)
let low = headerIdx + 1
let high = lines.length
let lastGood = headerIdx + 1
let tries = 0
const MAX_TRIES = 12
while (low <= high && tries < MAX_TRIES) {
const mid = Math.floor((low + high) / 2)
const candidate = [...head, ...lines.slice(headerIdx + 1, mid)].join('\n')
tries++
try {
await canParse(candidate, theme)
lastGood = mid
low = mid + 1
}
catch {
high = mid - 1
}
}
return [...head, ...lines.slice(headerIdx + 1, lastGood)].join('\n')
}
self.onmessage = async (ev: MessageEvent<Req>) => {
const msg = ev.data
const send = (res: Res) => self.postMessage(res)
const id = msg.id
try {
if (msg.action === 'canParse') {
const ok = await canParse(msg.payload.code, msg.payload.theme as Theme)
send({ id, ok: true, result: ok })
return
}
if (msg.action === 'findPrefix') {
const res = await findLastRenderablePrefix(
msg.payload.code,
msg.payload.theme as Theme,
)
send({ id, ok: true, result: res })
return
}
send({ id, ok: false, error: 'Unknown action' })
}
catch (e: any) {
send({ id, ok: false, error: e?.message ?? String(e) })
}
}
export {}
@@ -0,0 +1,237 @@
import { isMermaidEnabled } from '../components/MermaidBlockNode/mermaid'
type Theme = 'light' | 'dark'
let worker: Worker | null = null
let workerInitError: any = null
interface Pending {
resolve: (v: any) => void
reject: (e: any) => void
}
const rpcMap = new Map<string, Pending>()
// Basic concurrency cap to reduce flurries of in-flight checks during typing/polling
const MAX_CONCURRENCY_DEFAULT = 5
let maxConcurrency = MAX_CONCURRENCY_DEFAULT
// Runtime toggle for verbose logging
let DEBUG_CLIENT = false
export function setMermaidWorkerClientDebug(enabled: boolean) {
DEBUG_CLIENT = !!enabled
}
export function setMermaidWorkerMaxConcurrency(n: number) {
if (Number.isFinite(n) && n > 0)
maxConcurrency = Math.floor(n)
}
export function getMermaidWorkerLoad() {
return { inFlight: rpcMap.size, max: maxConcurrency }
}
export const MERMAID_WORKER_BUSY_CODE = 'WORKER_BUSY'
export const MERMAID_DISABLED_CODE = 'MERMAID_DISABLED'
/**
* Allow user to inject a Worker instance, e.g. from Vite ?worker import.
*/
export function setMermaidWorker(w: Worker) {
worker = w
workerInitError = null
const current = w
// Set up message handler to process worker responses
worker.onmessage = (e: MessageEvent) => {
if (worker !== current)
return
const { id, ok, result, error } = e.data
const p = rpcMap.get(id)
if (!p)
return
if (ok === false || error)
p.reject(new Error(error || 'Unknown error'))
else
p.resolve(result)
}
// Optional: handle worker errors
worker.onerror = (e: ErrorEvent) => {
if (worker !== current)
return
// If no pending calls, this might be a benign/late error; avoid noisy logs
if (rpcMap.size === 0) {
console.debug?.('[mermaidWorkerClient] Worker error (no pending):', e?.message || e)
return
}
// Downgrade to debug unless explicitly enabled
try {
if (DEBUG_CLIENT)
console.error('[mermaidWorkerClient] Worker error:', e?.message || e)
else
console.debug?.('[mermaidWorkerClient] Worker error:', e?.message || e)
}
catch {}
// Reject all pending requests
for (const [_id, p] of rpcMap.entries()) {
p.reject(new Error(`Worker error: ${e.message}`))
}
rpcMap.clear()
}
// Optional: messageerror indicates a data cloning issue; handle similarly but quiet when idle
;(worker as any).onmessageerror = (ev: MessageEvent) => {
if (worker !== current)
return
if (rpcMap.size === 0) {
console.debug?.('[mermaidWorkerClient] Worker messageerror (no pending):', ev)
return
}
try {
if (DEBUG_CLIENT)
console.error('[mermaidWorkerClient] Worker messageerror:', ev)
else
console.debug?.('[mermaidWorkerClient] Worker messageerror:', ev)
}
catch {}
for (const [_id, p] of rpcMap.entries()) {
p.reject(new Error('Worker messageerror'))
}
rpcMap.clear()
}
}
/**
* Remove the current worker instance (for cleanup or SSR).
*/
export function clearMermaidWorker() {
if (worker) {
try {
// Proactively reject any pending calls to avoid dangling timeouts
for (const [_id, p] of rpcMap.entries()) {
p.reject(new Error('Worker cleared'))
}
rpcMap.clear()
worker.terminate?.()
}
catch {}
}
worker = null
workerInitError = null
}
function ensureWorker() {
if (!worker) {
workerInitError = new Error('[mermaidWorkerClient] No worker instance set. Please inject a Worker via setMermaidWorker().')
; (workerInitError as any).name = 'WorkerInitError'
; (workerInitError as any).code = 'WORKER_INIT_ERROR'
return null
}
return worker
}
function callWorker<T>(action: 'canParse' | 'findPrefix', payload: any, timeout = 1400): Promise<T> {
if (!isMermaidEnabled()) {
const err: any = new Error('Mermaid rendering disabled')
err.name = 'MermaidDisabled'
err.code = MERMAID_DISABLED_CODE
return Promise.reject(err)
}
if (workerInitError)
return Promise.reject(workerInitError)
const wk = ensureWorker()
if (!wk)
return Promise.reject(workerInitError)
// Backpressure: avoid spamming the worker when many checks are queued
if (rpcMap.size >= maxConcurrency) {
const err: any = new Error('Worker busy')
err.name = 'WorkerBusy'
err.code = MERMAID_WORKER_BUSY_CODE
err.inFlight = rpcMap.size
err.max = maxConcurrency
return Promise.reject(err)
}
return new Promise<T>((resolve, reject) => {
const id = Math.random().toString(36).slice(2)
let settled = false
let timeoutId: any
const cleanup = () => {
if (settled)
return
settled = true
if (timeoutId != null)
(globalThis as any).clearTimeout(timeoutId)
rpcMap.delete(id)
}
const p: Pending = {
resolve: (v: any) => {
cleanup()
resolve(v)
},
reject: (e: any) => {
cleanup()
reject(e)
},
}
rpcMap.set(id, p)
try {
wk.postMessage({ id, action, payload })
}
catch (err) {
// postMessage can throw if worker is gone
rpcMap.delete(id)
reject(err)
return
}
timeoutId = (globalThis as any).setTimeout(() => {
const err: any = new Error('Worker call timed out')
err.name = 'WorkerTimeout'
err.code = 'WORKER_TIMEOUT'
const pending = rpcMap.get(id)
if (pending)
pending.reject(err)
}, timeout)
})
}
export async function canParseOffthread(code: string, theme: Theme, timeout = 1400) {
try {
return await callWorker<boolean>('canParse', { code, theme }, timeout)
}
catch (e) {
return Promise.reject(e)
}
}
export async function findPrefixOffthread(code: string, theme: Theme, timeout = 1400) {
try {
return await callWorker<string | null>('findPrefix', { code, theme }, timeout)
}
catch (e) {
return Promise.reject(e)
}
}
export function terminateWorker() {
if (worker) {
try {
// Reject all pending requests explicitly before termination to avoid late timeouts
for (const [_id, p] of rpcMap.entries()) {
p.reject(new Error('Worker terminated'))
}
rpcMap.clear()
worker.terminate()
}
finally {
worker = null
}
}
}