forked from boos/BoosAPI
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:
+223
@@ -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 }
|
||||
}
|
||||
+73
@@ -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.
|
||||
}
|
||||
})
|
||||
+333
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+259
@@ -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 }
|
||||
}
|
||||
+114
@@ -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 {}
|
||||
+201
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user