1import { APPEARANCES, materializeSvg, SvgNormalizationError, supportedAppearances, type SvgAppearance } from '../../../shared/svg/index.js'2import { ENGINE_IDS, type EngineId } from '../../../renderers/shared/engines'34export const BLOCK_TRANSFORM_PATH = '/transform/blocks'5export const TINY_TRANSFORM_PATH = '/transform/tiny'6export const DEFAULT_RENDER_UNIT_ORIGIN_PATTERN = 'https://{engine}.render.diagram.zip'7export const DEFAULT_APPEARANCE: SvgAppearance = 'auto-transparent'8const MAX_BODY_BYTES = 1_048_5769const MAX_BLOCKS = 3210const MAX_BLOCK_SOURCE_LENGTH = 524_28811const RENDER_TIMEOUT_MS = 20_00012const RENDER_CONCURRENCY = 613const MAX_ERROR_DETAIL_LENGTH = 20014const SIGNATURE_PREFIX = 'sha256='1516const LANG_ALIASES: ReadonlyMap<string, EngineId> = new Map<string, EngineId>([17 ...ENGINE_IDS.map((engine): [string, EngineId] => [engine, engine]),18 ['mmd', 'mermaid'],19 ['dot', 'graphviz'],20 ['puml', 'plantuml'],21 ['uml', 'plantuml'],22 ['c4', 'c4plantuml'],23 ['bob', 'svgbob'],24 ['vega-lite', 'vegalite'],25 ['drawio', 'diagramsnet'],26])2728export interface BlockTransformEnv {29 BLOCK_TRANSFORM_SECRET?: string30 TINY_TRANSFORM_SECRET?: string31 RENDER_UNIT_ORIGIN_PATTERN?: string32}3334export interface Block {35 index: number36 lang: string37 source: string38}3940export interface BlockArtifact {41 block: number42 engine: EngineId43 type: 'image/svg+xml'44 body: string45 appearance: SvgAppearance46}4748export interface BlockError {49 block: number50 engine: EngineId51 error: string52}5354interface BlockTransformRequest {55 appearance: SvgAppearance56 metadata: { title: string; description: string }57 blocks: Block[]58}5960class TransformError extends Error {61 constructor(readonly status: number, readonly code: string, message: string) {62 super(message)63 this.name = 'TransformError'64 }65}6667function json(value: unknown, status = 200, headers: Record<string, string> = {}): Response {68 return Response.json(value, {69 status,70 headers: {71 'cache-control': 'no-store',72 'content-type': 'application/json; charset=utf-8',73 'x-content-type-options': 'nosniff',74 ...headers,75 },76 })77}7879function errorResponse(error: unknown): Response {80 if (error instanceof TransformError) return json({ error: { code: error.code, message: error.message } }, error.status)81 console.error(JSON.stringify({ message: 'block transform error', error: error instanceof Error ? error.message : String(error) }))82 return json({ error: { code: 'internal_error', message: 'The transform could not be completed.' } }, 500)83}8485export function engineForLang(lang: string): EngineId | undefined {86 return LANG_ALIASES.get(lang.trim().toLowerCase())87}8889export function renderUnitUrl(engine: EngineId, pattern: string | undefined): string {90 const base = (pattern === undefined || pattern === '' ? DEFAULT_RENDER_UNIT_ORIGIN_PATTERN : pattern).replaceAll('{engine}', engine)91 return `${base.replace(/\/+$/, '')}/v1/svg`92}9394async function bodyBytes(request: Request): Promise<Uint8Array> {95 const length = request.headers.get('content-length')96 if (length !== null && (!/^\d+$/.test(length) || Number(length) > MAX_BODY_BYTES)) throw new TransformError(413, 'request_too_large', 'Transform request is too large.')97 if (request.body === null) throw new TransformError(400, 'invalid_request', 'A JSON request body is required.')98 const reader = request.body.getReader()99 const chunks: Uint8Array[] = []100 let total = 0101 while (true) {102 const item = await reader.read()103 if (item.done) break104 total += item.value.byteLength105 if (total > MAX_BODY_BYTES) {106 await reader.cancel('request too large')107 throw new TransformError(413, 'request_too_large', 'Transform request is too large.')108 }109 chunks.push(item.value)110 }111 const bytes = new Uint8Array(total)112 let offset = 0113 for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength }114 return bytes115}116117function hexBytes(value: string): Uint8Array | undefined {118 if (value.length === 0 || value.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(value)) return undefined119 const bytes = new Uint8Array(value.length / 2)120 for (let index = 0; index < bytes.length; index += 1) bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16)121 return bytes122}123124export async function signBody(secret: string, body: BufferSource): Promise<string> {125 const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])126 const digest = new Uint8Array(await crypto.subtle.sign('HMAC', key, body))127 return `${SIGNATURE_PREFIX}${Array.from(digest, byte => byte.toString(16).padStart(2, '0')).join('')}`128}129130async function verifySignature(secret: string, body: Uint8Array, header: string | null, headerName: string): Promise<void> {131 const presented = header?.trim() ?? ''132 const provided = presented.toLowerCase().startsWith(SIGNATURE_PREFIX) ? hexBytes(presented.slice(SIGNATURE_PREFIX.length)) : undefined133 const expected = hexBytes((await signBody(secret, body)).slice(SIGNATURE_PREFIX.length))134 if (provided === undefined || expected === undefined || provided.byteLength !== expected.byteLength || !crypto.subtle.timingSafeEqual(provided, expected)) {135 throw new TransformError(401, 'invalid_signature', `${headerName} does not match the request body.`)136 }137}138139function objectValue(value: unknown, name: string): Record<string, unknown> {140 if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new TransformError(400, 'invalid_request', `${name} must be an object.`)141 return value as Record<string, unknown>142}143144function parseBlock(value: unknown, position: number): Block {145 const block = objectValue(value, `blocks[${position}]`)146 const index = block.index ?? position147 if (!Number.isInteger(index) || Number(index) < 0) throw new TransformError(400, 'invalid_request', `blocks[${position}].index must be a non-negative integer.`)148 if (typeof block.lang !== 'string') throw new TransformError(400, 'invalid_request', `blocks[${position}].lang must be a string.`)149 if (typeof block.source !== 'string') throw new TransformError(400, 'invalid_request', `blocks[${position}].source must be a string.`)150 if (block.source.length > MAX_BLOCK_SOURCE_LENGTH) throw new TransformError(413, 'block_too_large', `blocks[${position}].source is larger than ${MAX_BLOCK_SOURCE_LENGTH} characters.`)151 return { index: Number(index), lang: block.lang, source: block.source }152}153154function parseTransformRequest(bytes: Uint8Array): BlockTransformRequest {155 let input: unknown156 try { input = JSON.parse(new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }).decode(bytes)) } catch {157 throw new TransformError(400, 'invalid_json', 'Transform request is not valid JSON.')158 }159 const body = objectValue(input, 'request')160 if ('event' in body) throw new TransformError(400, 'unexpected_field', 'The transform request must not include the source event; send source and blocks only.')161 if (body.source !== undefined) objectValue(body.source, 'source')162 if (!Array.isArray(body.blocks)) throw new TransformError(400, 'invalid_request', 'blocks must be an array.')163 if (body.blocks.length > MAX_BLOCKS) throw new TransformError(413, 'too_many_blocks', `blocks cannot contain more than ${MAX_BLOCKS} entries.`)164 const appearance = body.appearance ?? DEFAULT_APPEARANCE165 if (typeof appearance !== 'string' || !(APPEARANCES as readonly string[]).includes(appearance)) {166 throw new TransformError(400, 'invalid_appearance', `appearance must be one of ${APPEARANCES.join(', ')}.`)167 }168 return {169 appearance: appearance as SvgAppearance,170 metadata: { title: '', description: '' },171 blocks: body.blocks.map(parseBlock),172 }173}174175async function failureDetail(response: Response): Promise<string> {176 let text = ''177 try { text = (await response.text()).slice(0, 4_096) } catch { return '' }178 try {179 const payload = JSON.parse(text) as { error?: { message?: unknown } }180 if (typeof payload?.error?.message === 'string') return payload.error.message181 } catch { /* plain text failure body */ }182 return text.replace(/\s+/g, ' ').trim().slice(0, MAX_ERROR_DETAIL_LENGTH)183}184185function chooseAppearance(canonical: string, requested: SvgAppearance): SvgAppearance {186 if (requested === 'raw') return 'raw'187 return (supportedAppearances(canonical) as readonly string[]).includes(requested) ? requested : 'raw'188}189190async function renderBlock(block: Block, engine: EngineId, request: BlockTransformRequest, env: BlockTransformEnv): Promise<BlockArtifact | BlockError> {191 try {192 const response = await fetch(renderUnitUrl(engine, env.RENDER_UNIT_ORIGIN_PATTERN), {193 method: 'POST',194 headers: { accept: 'image/svg+xml', 'content-type': 'application/json' },195 body: JSON.stringify({196 source: block.source,197 format: 'svg',198 options: {},199 metadata: request.metadata,200 presentation: { background: '', padding: 0, frame: false },201 }),202 signal: AbortSignal.timeout(RENDER_TIMEOUT_MS),203 })204 if (!response.ok) {205 const detail = await failureDetail(response)206 return { block: block.index, engine, error: `renderer answered ${response.status}${detail === '' ? '' : `: ${detail}`}` }207 }208 const canonical = await response.text()209 const appearance = chooseAppearance(canonical, request.appearance)210 return { block: block.index, engine, type: 'image/svg+xml', body: materializeSvg(canonical, appearance), appearance }211 } catch (error) {212 if (error instanceof SvgNormalizationError) return { block: block.index, engine, error: `renderer output rejected: ${error.message}` }213 if (error instanceof Error && error.name === 'TimeoutError') return { block: block.index, engine, error: `renderer timed out after ${RENDER_TIMEOUT_MS / 1_000} seconds` }214 return { block: block.index, engine, error: `renderer unreachable: ${error instanceof Error ? error.message : String(error)}` }215 }216}217218async function renderAll(request: BlockTransformRequest, env: BlockTransformEnv): Promise<Array<BlockArtifact | BlockError>> {219 const jobs = request.blocks.flatMap(block => {220 const engine = engineForLang(block.lang)221 return engine === undefined ? [] : [{ block, engine }]222 })223 const results = new Array<BlockArtifact | BlockError>(jobs.length)224 let next = 0225 const worker = async (): Promise<void> => {226 while (next < jobs.length) {227 const position = next228 next += 1229 const job = jobs[position]230 results[position] = await renderBlock(job.block, job.engine, request, env)231 }232 }233 await Promise.all(Array.from({ length: Math.min(RENDER_CONCURRENCY, jobs.length) }, worker))234 return results235}236237export async function blockTransform(request: Request, env: BlockTransformEnv, legacy = false): Promise<Response> {238 try {239 if (request.method !== 'POST') return json({ error: { code: 'method_not_allowed', message: 'Use POST for the block transform endpoint.' } }, 405, { allow: 'POST' })240 const secretName = legacy ? 'TINY_TRANSFORM_SECRET' : 'BLOCK_TRANSFORM_SECRET'241 const secret = legacy ? (env.TINY_TRANSFORM_SECRET ?? env.BLOCK_TRANSFORM_SECRET) : env.BLOCK_TRANSFORM_SECRET242 if (typeof secret !== 'string' || secret === '') return json({ error: { code: 'transform_unavailable', message: `${secretName} is not configured.` } }, 503)243 const bytes = await bodyBytes(request)244 const signatureHeader = legacy ? 'X-Tiny-Signature' : 'X-Transform-Signature'245 await verifySignature(secret, bytes, request.headers.get(legacy ? 'x-tiny-signature' : 'x-transform-signature'), signatureHeader)246 const results = await renderAll(parseTransformRequest(bytes), env)247 return json({248 artifacts: results.filter((result): result is BlockArtifact => 'body' in result),249 errors: results.filter((result): result is BlockError => 'error' in result),250 })251 } catch (error) {252 return errorResponse(error)253 }254}