1import '../../artifacts/d2/wasm_exec.js'2import wasmModule from '../../artifacts/d2/d2-custom.wasm'3import { RenderError } from '../runtime/errors'4import type { RendererAdapter } from '../runtime/types'5import { edgeResult } from './types'67const VERSION = 'd2@0.7.1/dagre-wasm-1'8const MAX_SOURCE_BYTES = 512 * 10249const SUPPORTED_OPTIONS = new Set(['layout', 'animate-interval'])1011type GoRuntime = { importObject: WebAssembly.Imports; run(instance: WebAssembly.Instance): Promise<unknown> }12type D2Global = typeof globalThis & { d2CustomRender?: (source: string, options?: string) => string }13type D2Response = { svg?: string; error?: string }1415let initPromise: Promise<void> | undefined16let renderTail: Promise<void> = Promise.resolve()1718function styleDeclaration(style: string, name: string): string | null {19 for (const declaration of style.split(';')) {20 const separator = declaration.indexOf(':')21 if (separator < 0 || declaration.slice(0, separator).trim().toLowerCase() !== name) continue22 return declaration.slice(separator + 1).trim()23 }24 return null25}2627export function imageCompatibleAnimations(svg: string): string {28 return svg.replace(/<path\b(?=[^>]*\bclass="[^"]*\banimated-connection\b[^"]*")(?=[^>]*\bstyle="([^"]*)")[^>]*>/g, (tag, style: string) => {29 const offset = styleDeclaration(style, 'stroke-dashoffset')30 const animation = styleDeclaration(style, 'animation')31 const duration = animation?.match(/^dashdraw\s+(\d+(?:\.\d+)?)s\s+linear\s+infinite$/)?.[1]32 if (!offset || !/^-?\d+(?:\.\d+)?(?:px)?$/.test(offset) || !duration) return tag33 const imageCompatibleStyle = style34 .split(';')35 .filter(declaration => declaration.trim() && !/^\s*(?:animation|stroke-dashoffset)\s*:/i.test(declaration))36 .join(';')37 const normalizedTag = tag38 .replace(/\s+mask="[^"]*"/, '')39 .replace(/\bstyle="[^"]*"/, `style="${imageCompatibleStyle};"`)40 const animate = `<animate attributeName="stroke-dashoffset" from="0" to="${offset}" dur="${duration}s" calcMode="linear" repeatCount="indefinite"></animate>`41 return /\/\s*>$/.test(normalizedTag)42 ? `${normalizedTag.replace(/\/\s*>$/, '>')}${animate}</path>`43 : `${normalizedTag}${animate}`44 })45}4647async function init(): Promise<void> {48 if (initPromise) return initPromise49 initPromise = (async () => {50 const go = new (globalThis as unknown as { Go: new () => GoRuntime }).Go()51 const result = await WebAssembly.instantiate(wasmModule, go.importObject) as unknown as WebAssembly.Instance | { instance: WebAssembly.Instance }52 const instance = result instanceof WebAssembly.Instance ? result : result.instance53 void go.run(instance)54 for (let i = 0; i < 100; i++) {55 if ((globalThis as D2Global).d2CustomRender) return56 await new Promise(resolve => setTimeout(resolve, 1))57 }58 throw new Error('D2 Wasm runtime did not initialize.')59 })()60 return initPromise61}6263function withRenderLock<T>(operation: () => Promise<T>): Promise<T> {64 const previous = renderTail65 let release!: () => void66 renderTail = new Promise(resolve => { release = resolve })67 return previous.then(async () => {68 try { return await operation() } finally { release() }69 })70}7172export const d2Adapter: RendererAdapter = {73 id: 'd2', runtime: 'edge-wasm', version: VERSION,74 async render(request, signal) {75 if (signal.aborted) throw signal.reason76 if (new TextEncoder().encode(request.source).byteLength > MAX_SOURCE_BYTES) {77 throw new RenderError(413, 'source_too_large', 'D2 source exceeds the 512 KiB limit.')78 }79 for (const name of Object.keys(request.options)) {80 if (!SUPPORTED_OPTIONS.has(name)) {81 throw new RenderError(400, 'unsupported_options', `Unsupported D2 option: ${name}.`)82 }83 }84 if (request.options.layout !== undefined && request.options.layout.toLowerCase() !== 'dagre') {85 throw new RenderError(400, 'unsupported_options', 'D2 supports Dagre layout in the edge unit; ELK is unavailable.')86 }87 const animateInterval = request.options['animate-interval']88 if (animateInterval !== undefined && (!/^\d+$/.test(animateInterval) || Number(animateInterval) < 1 || Number(animateInterval) > 60_000)) {89 throw new RenderError(400, 'invalid_options', 'D2 animate-interval must be an integer from 1 to 60000 milliseconds.')90 }91 await init()92 return withRenderLock(async () => {93 if (signal.aborted) throw signal.reason94 const render = (globalThis as D2Global).d2CustomRender95 if (!render) throw new RenderError(500, 'renderer_unavailable', 'D2 Wasm renderer is unavailable.')96 let result: D2Response97 try { result = JSON.parse(render(request.source, animateInterval === undefined ? '' : JSON.stringify({ animateInterval: Number(animateInterval) }))) as D2Response } catch (error) {98 throw new RenderError(422, 'render_failed', error instanceof Error ? error.message : String(error))99 }100 if (result.error) throw new RenderError(422, 'render_failed', result.error.slice(0, 500))101 if (!result.svg || result.svg.trim() === '') throw new RenderError(422, 'empty_render', 'D2 returned no SVG.')102 return edgeResult('d2', VERSION, imageCompatibleAnimations(result.svg), 'edge-wasm')103 })104 },105}