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:
@@ -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 }
|
||||
}
|
||||
+78
@@ -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 { }
|
||||
})
|
||||
+437
@@ -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 }
|
||||
}
|
||||
+118
@@ -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 {}
|
||||
+237
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user