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,223 @@
export type KaTeXCDNWorkerMode = 'classic' | 'module'
export interface KaTeXCDNWorkerOptions {
katexUrl: string
mhchemUrl?: string
mode?: KaTeXCDNWorkerMode
debug?: boolean
workerOptions?: WorkerOptions
renderOptions?: {
throwOnError?: boolean
output?: string
strict?: string
}
}
export interface KaTeXCDNWorkerHandle {
worker: Worker | null
dispose: () => void
source: string
}
function stringifyForWorker(value: any) {
return JSON.stringify(value)
}
export function buildKaTeXCDNWorkerSource(options: KaTeXCDNWorkerOptions): string {
const mode: KaTeXCDNWorkerMode = options.mode ?? 'classic'
const renderOptions = {
throwOnError: true,
displayMode: true,
output: 'html',
strict: 'ignore',
...(options.renderOptions || {}),
}
const renderOptionsLiteral = stringifyForWorker(renderOptions)
const katexUrlLiteral = stringifyForWorker(options.katexUrl)
const mhchemUrlLiteral = options.mhchemUrl ? stringifyForWorker(options.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) {
if (DEBUG)
console.warn('[markstream-angular: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
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) {
globalThis.postMessage({ id, error: String(err?.message || err), content, displayMode })
}
})
`.trimStart()
}
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) {
if (DEBUG)
console.warn('[markstream-angular: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
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) {
globalThis.postMessage({ id, error: String(err?.message || err), content, displayMode })
}
})
`.trimStart()
}
export function createKaTeXWorkerFromCDN(options: KaTeXCDNWorkerOptions): KaTeXCDNWorkerHandle {
const source = buildKaTeXCDNWorkerSource(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 {
// Ignore revoke failures.
}
}
const mode = 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.postMessage({ type: 'init', debug: true })
}
catch {
// Ignore init messaging failures.
}
}
return { worker, dispose, source }
}
@@ -0,0 +1,73 @@
import katex from 'katex'
import 'katex/dist/contrib/mhchem'
interface MessageIn {
type?: 'init' | 'render'
id?: string
content?: string
displayMode?: boolean
debug?: boolean
}
interface MessageOut {
id: string
html?: string
error?: string
}
let debugWorker = false
;(globalThis as any).addEventListener('message', (event: MessageEvent<MessageIn>) => {
const data = event.data || {}
if (data.type === 'init') {
debugWorker = !!data.debug
return
}
const id = data.id ?? ''
const content = data.content ?? ''
const displayMode = data.displayMode ?? true
try {
if (debugWorker)
console.debug('[markstream-angular:katexRenderer.worker] render start', { id, displayMode, content })
const html = katex.renderToString(content, {
throwOnError: true,
displayMode,
output: 'html',
strict: 'ignore',
})
const result: MessageOut & { content: string, displayMode: boolean } = {
id,
html,
content,
displayMode,
}
;(globalThis as any).postMessage(result)
}
catch (error: any) {
const result: MessageOut & { content: string, displayMode: boolean } = {
id,
error: String(error?.message ?? error),
content,
displayMode,
}
;(globalThis as any).postMessage(result)
}
})
;(globalThis as any).addEventListener('error', (event: ErrorEvent) => {
try {
;(globalThis as any).postMessage({
id: '__worker_uncaught__',
error: String(event.message ?? event.error),
content: '',
displayMode: true,
})
}
catch {
// Ignore postMessage failures while surfacing uncaught worker errors.
}
})
@@ -0,0 +1,333 @@
import { isKatexEnabled } from '../optional/katex'
import { normalizeKaTeXRenderInput } from '../utils/normalizeKaTeXRenderInput'
export const WORKER_BUSY_CODE = 'WORKER_BUSY'
interface Pending {
resolve: (val: string) => void
reject: (err: any) => void
timeoutId: number
}
let worker: Worker | null = null
let workerInitError: any = null
let debugKatexWorker = false
const pending = new Map<string, Pending>()
const cache = new Map<string, string>()
const cacheMax = 200
const drainWaiters = new Set<() => void>()
let maxConcurrency = 5
function notifyDrainIfBelowCap() {
if (pending.size >= maxConcurrency || drainWaiters.size === 0)
return
const currentWaiters = Array.from(drainWaiters)
drainWaiters.clear()
for (const waiter of currentWaiters) {
try {
waiter()
}
catch {
// Ignore waiter failures.
}
}
}
function rememberCache(content: string, displayMode: boolean, html: string) {
cache.set(`${displayMode ? 'd' : 'i'}:${content}`, html)
if (cache.size > cacheMax) {
const firstKey = cache.keys().next().value
if (firstKey)
cache.delete(firstKey)
}
}
export function setKaTeXWorker(nextWorker: Worker) {
worker = nextWorker
workerInitError = null
worker.onmessage = (event: MessageEvent) => {
const { id, html, error, content, displayMode } = event.data || {}
const active = pending.get(id)
if (!active)
return
pending.delete(id)
clearTimeout(active.timeoutId)
notifyDrainIfBelowCap()
if (error) {
active.reject(new Error(error))
return
}
if (content)
rememberCache(String(content), Boolean(displayMode), String(html || ''))
active.resolve(String(html || ''))
}
worker.onerror = (event: ErrorEvent) => {
for (const [, active] of pending.entries()) {
clearTimeout(active.timeoutId)
active.reject(new Error(`Worker error: ${event.message}`))
}
pending.clear()
notifyDrainIfBelowCap()
}
if (debugKatexWorker)
worker.postMessage({ type: 'init', debug: true })
}
export function clearKaTeXWorker() {
if (worker)
worker.terminate?.()
worker = null
workerInitError = null
}
function ensureWorker() {
if (worker)
return worker
workerInitError = new Error('[markstream-angular: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
}
export function setKaTeXWorkerDebug(enabled: boolean) {
debugKatexWorker = !!enabled
if (worker)
worker.postMessage({ type: 'init', debug: debugKatexWorker })
}
export async function renderKaTeXInWorker(content: string, displayMode = true, timeout = 2000, signal?: AbortSignal): Promise<string> {
const normalizedContent = normalizeKaTeXRenderInput(content)
if (!isKatexEnabled()) {
const error = new Error('KaTeX rendering disabled')
;(error as any).name = 'KaTeXDisabled'
;(error as any).code = 'KATEX_DISABLED'
return Promise.reject(error)
}
if (workerInitError)
return Promise.reject(workerInitError)
const cacheKey = `${displayMode ? 'd' : 'i'}:${normalizedContent}`
const cached = cache.get(cacheKey)
if (cached)
return cached
const activeWorker = ensureWorker()
if (!activeWorker)
return Promise.reject(workerInitError)
if (pending.size >= maxConcurrency) {
const error = new Error('Worker busy')
;(error as any).name = 'WorkerBusy'
;(error as any).code = WORKER_BUSY_CODE
;(error as any).busy = true
;(error as any).inFlight = pending.size
;(error as any).max = maxConcurrency
return Promise.reject(error)
}
return new Promise((resolve, reject) => {
if (signal?.aborted) {
const error = new Error('Aborted')
;(error as any).name = 'AbortError'
reject(error)
return
}
const id = Math.random().toString(36).slice(2)
const timeoutId = globalThis.setTimeout(() => {
pending.delete(id)
notifyDrainIfBelowCap()
const error = new Error('Worker render timed out')
;(error as any).name = 'WorkerTimeout'
;(error as any).code = 'WORKER_TIMEOUT'
reject(error)
}, timeout)
const onAbort = () => {
globalThis.clearTimeout(timeoutId)
if (pending.delete(id))
notifyDrainIfBelowCap()
const error = new Error('Aborted')
;(error as any).name = 'AbortError'
reject(error)
}
signal?.addEventListener('abort', onAbort, { once: true })
pending.set(id, {
timeoutId,
resolve: (html) => {
signal?.removeEventListener('abort', onAbort)
resolve(html)
},
reject: (error) => {
signal?.removeEventListener('abort', onAbort)
reject(error)
},
})
activeWorker.postMessage({
id,
type: 'render',
content: normalizedContent,
displayMode,
})
})
}
export function setKaTeXCache(content: string, displayMode = true, html: string) {
rememberCache(normalizeKaTeXRenderInput(content), displayMode, html)
}
export function getKaTeXWorkerLoad() {
return {
inFlight: pending.size,
max: maxConcurrency,
}
}
export function setKaTeXWorkerMaxConcurrency(value: number) {
if (Number.isFinite(value) && value > 0)
maxConcurrency = Math.floor(value)
}
export function isKaTeXWorkerBusy() {
return pending.size >= maxConcurrency
}
export function waitForKaTeXWorkerSlot(timeout = 2000, signal?: AbortSignal): Promise<void> {
if (pending.size < maxConcurrency)
return Promise.resolve()
return new Promise((resolve, reject) => {
let settled = false
let timer: ReturnType<typeof setTimeout> | null = null
const onDrain = () => {
if (settled)
return
settled = true
if (timer)
clearTimeout(timer)
drainWaiters.delete(onDrain)
resolve()
}
drainWaiters.add(onDrain)
timer = setTimeout(() => {
if (settled)
return
settled = true
drainWaiters.delete(onDrain)
const error = new Error('Wait for worker slot timed out')
;(error as any).name = 'WorkerBusyTimeout'
;(error as any).code = 'WORKER_BUSY_TIMEOUT'
reject(error)
}, timeout)
queueMicrotask(() => notifyDrainIfBelowCap())
if (signal) {
const onAbort = () => {
if (settled)
return
settled = true
if (timer)
clearTimeout(timer)
drainWaiters.delete(onDrain)
const error = new Error('Aborted')
;(error as any).name = 'AbortError'
reject(error)
}
if (signal.aborted)
onAbort()
else
signal.addEventListener('abort', onAbort, { once: true })
}
})
}
export interface BackpressureOptions {
timeout?: number
waitTimeout?: number
backoffMs?: number
maxRetries?: number
signal?: AbortSignal
}
const defaultBackpressure = {
timeout: 2000,
waitTimeout: 1500,
backoffMs: 30,
maxRetries: 1,
}
const maxBackpressureRetries = 8
export function setKaTeXBackpressureDefaults(options: Partial<typeof defaultBackpressure>) {
if (options.timeout != null)
defaultBackpressure.timeout = Math.max(0, Math.floor(options.timeout))
if (options.waitTimeout != null)
defaultBackpressure.waitTimeout = Math.max(0, Math.floor(options.waitTimeout))
if (options.backoffMs != null)
defaultBackpressure.backoffMs = Math.max(0, Math.floor(options.backoffMs))
if (options.maxRetries != null)
defaultBackpressure.maxRetries = Math.max(0, Math.floor(options.maxRetries))
}
export function getKaTeXBackpressureDefaults() {
return { ...defaultBackpressure }
}
export async function renderKaTeXWithBackpressure(
content: string,
displayMode = true,
options: BackpressureOptions = {},
): Promise<string> {
if (!isKatexEnabled()) {
const error = new Error('KaTeX rendering disabled')
;(error as any).name = 'KaTeXDisabled'
;(error as any).code = 'KATEX_DISABLED'
throw error
}
const timeout = options.timeout ?? defaultBackpressure.timeout
const waitTimeout = options.waitTimeout ?? defaultBackpressure.waitTimeout
const backoffMs = options.backoffMs ?? defaultBackpressure.backoffMs
const rawMaxRetries = options.maxRetries ?? defaultBackpressure.maxRetries
const maxRetries = Number.isFinite(rawMaxRetries)
? Math.max(0, Math.min(Math.floor(rawMaxRetries), maxBackpressureRetries))
: defaultBackpressure.maxRetries
let attempt = 0
for (;;) {
if (options.signal?.aborted) {
const error = new Error('Aborted')
;(error as any).name = 'AbortError'
throw error
}
try {
return await renderKaTeXInWorker(content, displayMode, timeout, options.signal)
}
catch (error: any) {
if (error?.code !== WORKER_BUSY_CODE || attempt >= maxRetries)
throw error
attempt += 1
await waitForKaTeXWorkerSlot(waitTimeout, options.signal).catch(() => {})
if (backoffMs > 0)
await new Promise(resolve => globalThis.setTimeout(resolve, backoffMs * attempt))
}
}
}
@@ -0,0 +1,259 @@
export type MermaidCDNWorkerMode = 'classic' | 'module'
export interface MermaidCDNWorkerOptions {
mermaidUrl: string
mode?: MermaidCDNWorkerMode
debug?: boolean
workerOptions?: WorkerOptions
initializeOptions?: Record<string, any>
}
export interface MermaidCDNWorkerHandle {
worker: Worker | null
dispose: () => void
source: string
}
function stringifyForWorker(value: any) {
return JSON.stringify(value)
}
export function buildMermaidCDNWorkerSource(options: MermaidCDNWorkerOptions): string {
const mode: MermaidCDNWorkerMode = options.mode ?? 'module'
const mermaidUrlLiteral = stringifyForWorker(options.mermaidUrl)
const initLiteral = stringifyForWorker({
startOnLoad: false,
securityLevel: 'loose',
...(options.initializeOptions || {}),
})
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 = String(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 line = (lines[i] || '').trim()
if (!line || line.startsWith('%%'))
continue
if (headerRe.test(line))
return i
}
return -1
}
async function canParse(code, theme) {
const themed = applyThemeTo(code, theme)
if (mermaid && typeof mermaid.parse === 'function') {
await mermaid.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 headerIndex = findHeaderIndex(lines)
if (headerIndex === -1)
return null
const head = lines.slice(0, headerIndex + 1)
await canParse(head.join('\\n'), theme)
let low = headerIndex + 1
let high = lines.length
let lastGood = headerIndex + 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(headerIndex + 1, mid)].join('\\n')
tries += 1
try {
await canParse(candidate, theme)
lastGood = mid
low = mid + 1
}
catch {
high = mid - 1
}
}
return [...head, ...lines.slice(headerIndex + 1, lastGood)].join('\\n')
}
function initMermaidOnce() {
if (!mermaid)
return
try {
if (typeof mermaid.initialize === 'function')
mermaid.initialize(${initLiteral})
}
catch (e) {
if (DEBUG)
console.warn('[markstream-angular:mermaid-cdn-worker] initialize failed', e)
}
}
globalThis.addEventListener('message', async (ev) => {
const msg = ev.data || {}
if (msg.type === 'init') {
DEBUG = !!msg.debug
return
}
const id = msg.id
const action = msg.action
const payload = msg.payload || {}
if (!mermaid) {
const error = mermaidLoadError ? String(mermaidLoadError?.message || mermaidLoadError) : 'Mermaid is not available in worker'
globalThis.postMessage({ id, ok: false, error })
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 result = await findLastRenderablePrefix(payload.code, payload.theme)
globalThis.postMessage({ id, ok: true, result })
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
}
await loadMermaid()
`.trimStart()
}
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 {
// Ignore revoke failures.
}
}
const mode = 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.postMessage({ type: 'init', debug: true })
}
catch {
// Ignore init messaging failures.
}
}
return { worker, dispose, source }
}
@@ -0,0 +1,114 @@
/// <reference lib="webworker" />
import mermaid from 'mermaid'
declare const self: DedicatedWorkerGlobalScope
mermaid.initialize({ startOnLoad: false, securityLevel: 'loose' })
type Theme = 'light' | 'dark'
interface RequestMessage {
id: string
action: 'canParse' | 'findPrefix' | (string & {})
payload: { code: string, theme: Theme }
}
type ResponseMessage
= | { 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 index = 0; index < lines.length; index += 1) {
const line = lines[index].trim()
if (!line || line.startsWith('%%'))
continue
if (headerRe.test(line))
return index
}
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
}
throw new Error('mermaid.parse not available in worker')
}
async function findLastRenderablePrefix(baseCode: string, theme: Theme) {
const lines = baseCode.split('\n')
const headerIndex = findHeaderIndex(lines)
if (headerIndex === -1)
return null
const head = lines.slice(0, headerIndex + 1)
await canParse(head.join('\n'), theme)
let low = headerIndex + 1
let high = lines.length
let lastGood = headerIndex + 1
let tries = 0
const maxTries = 12
while (low <= high && tries < maxTries) {
const mid = Math.floor((low + high) / 2)
const candidate = [...head, ...lines.slice(headerIndex + 1, mid)].join('\n')
tries += 1
try {
await canParse(candidate, theme)
lastGood = mid
low = mid + 1
}
catch {
high = mid - 1
}
}
return [...head, ...lines.slice(headerIndex + 1, lastGood)].join('\n')
}
self.onmessage = async (event: MessageEvent<RequestMessage>) => {
const message = event.data
const send = (response: ResponseMessage) => self.postMessage(response)
try {
if (message.action === 'canParse') {
send({
id: message.id,
ok: true,
result: await canParse(message.payload.code, message.payload.theme),
})
return
}
if (message.action === 'findPrefix') {
send({
id: message.id,
ok: true,
result: await findLastRenderablePrefix(message.payload.code, message.payload.theme),
})
return
}
send({ id: message.id, ok: false, error: 'Unknown action' })
}
catch (error: any) {
send({ id: message.id, ok: false, error: error?.message ?? String(error) })
}
}
export {}
@@ -0,0 +1,201 @@
import { isMermaidEnabled } from '../optional/mermaid'
type Theme = 'light' | 'dark'
let worker: Worker | null = null
let workerInitError: any = null
interface Pending {
resolve: (value: any) => void
reject: (error: any) => void
}
const rpcMap = new Map<string, Pending>()
let maxConcurrency = 5
let debugClient = false
export function setMermaidWorkerClientDebug(enabled: boolean) {
debugClient = !!enabled
}
export function setMermaidWorkerMaxConcurrency(value: number) {
if (Number.isFinite(value) && value > 0)
maxConcurrency = Math.floor(value)
}
export function getMermaidWorkerLoad() {
return { inFlight: rpcMap.size, max: maxConcurrency }
}
export const MERMAID_WORKER_BUSY_CODE = 'WORKER_BUSY'
export const MERMAID_DISABLED_CODE = 'MERMAID_DISABLED'
export function setMermaidWorker(nextWorker: Worker) {
worker = nextWorker
workerInitError = null
const current = nextWorker
worker.onmessage = (event: MessageEvent) => {
if (worker !== current)
return
const { id, ok, result, error } = event.data || {}
const active = rpcMap.get(id)
if (!active)
return
if (ok === false || error)
active.reject(new Error(error || 'Unknown error'))
else
active.resolve(result)
}
worker.onerror = (event: ErrorEvent) => {
if (worker !== current)
return
if (rpcMap.size === 0) {
console.debug?.('[markstream-angular:mermaidWorkerClient] Worker error (idle):', event?.message || event)
return
}
try {
if (debugClient)
console.error('[markstream-angular:mermaidWorkerClient] Worker error:', event?.message || event)
else
console.debug?.('[markstream-angular:mermaidWorkerClient] Worker error:', event?.message || event)
}
catch {
// Ignore logging failures.
}
for (const [, active] of rpcMap.entries())
active.reject(new Error(`Worker error: ${event.message}`))
rpcMap.clear()
}
;(worker as any).onmessageerror = (event: MessageEvent) => {
if (worker !== current)
return
if (rpcMap.size === 0) {
console.debug?.('[markstream-angular:mermaidWorkerClient] Worker messageerror (idle):', event)
return
}
for (const [, active] of rpcMap.entries())
active.reject(new Error('Worker messageerror'))
rpcMap.clear()
}
}
export function clearMermaidWorker() {
if (worker) {
try {
for (const [, active] of rpcMap.entries())
active.reject(new Error('Worker cleared'))
rpcMap.clear()
worker.terminate?.()
}
catch {
// Ignore worker termination failures.
}
}
worker = null
workerInitError = null
}
function ensureWorker() {
if (worker)
return worker
workerInitError = new Error('[markstream-angular: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
}
function callWorker<T>(action: 'canParse' | 'findPrefix', payload: any, timeout = 1400): Promise<T> {
if (!isMermaidEnabled()) {
const error: any = new Error('Mermaid rendering disabled')
error.name = 'MermaidDisabled'
error.code = MERMAID_DISABLED_CODE
return Promise.reject(error)
}
if (workerInitError)
return Promise.reject(workerInitError)
const activeWorker = ensureWorker()
if (!activeWorker)
return Promise.reject(workerInitError)
if (rpcMap.size >= maxConcurrency) {
const error: any = new Error('Worker busy')
error.name = 'WorkerBusy'
error.code = MERMAID_WORKER_BUSY_CODE
error.inFlight = rpcMap.size
error.max = maxConcurrency
return Promise.reject(error)
}
return new Promise<T>((resolve, reject) => {
const id = Math.random().toString(36).slice(2)
let settled = false
let timeoutId: ReturnType<typeof setTimeout> | null = null
const cleanup = () => {
if (settled)
return
settled = true
if (timeoutId != null)
clearTimeout(timeoutId)
rpcMap.delete(id)
}
rpcMap.set(id, {
resolve: (value: any) => {
cleanup()
resolve(value)
},
reject: (error: any) => {
cleanup()
reject(error)
},
})
try {
activeWorker.postMessage({ id, action, payload })
}
catch (error) {
rpcMap.delete(id)
reject(error)
return
}
timeoutId = setTimeout(() => {
const error: any = new Error('Worker call timed out')
error.name = 'WorkerTimeout'
error.code = 'WORKER_TIMEOUT'
const pending = rpcMap.get(id)
pending?.reject(error)
}, timeout)
})
}
export async function canParseOffthread(code: string, theme: Theme, timeout = 1400) {
return await callWorker<boolean>('canParse', { code, theme }, timeout)
}
export async function findPrefixOffthread(code: string, theme: Theme, timeout = 1400) {
return await callWorker<string | null>('findPrefix', { code, theme }, timeout)
}
export function terminateWorker() {
if (worker) {
try {
for (const [, active] of rpcMap.entries())
active.reject(new Error('Worker terminated'))
rpcMap.clear()
worker.terminate()
}
finally {
worker = null
}
}
}