- commit
- ed32e765fac7231eb7062717a3736290c98a235f
- parent
- 8dada911fdcece1be97e56dce4b610028b9ecc38
- author
- Dami <dami.etoile@gmail.com>
| M | services/ | 5 | +- |
| A | services/ | 259 | ++++++++++++++++++++++++++++++ |
| M | services/ | 2 | +- |
@@ -1,4 +1,5 @@
11 import { attachEditableDocument, extractEditableDocument } from '../../../shared/svg/index.js'2+import { TINY_TRANSFORM_PATH, tinyTransform, type TinyTransformEnv } from './tiny-transform'23 34 const ALIAS_PATH = /^\/d\/[A-Za-z0-9_-]{16}$/45 const PACKED_SVG_PATH = /^\/svg\/([A-Za-z0-9_-]+)$/@@ -84,6 +85,8 @@ export default {
8485 async fetch(request, env): Promise<Response> {8586 const url = new URL(request.url)8687 88+ if (url.pathname === TINY_TRANSFORM_PATH) return tinyTransform(request, env)89+8790 if (request.method !== 'GET' && request.method !== 'HEAD') {8891 return textResponse('Not found', 404, request.method)8992 }@@ -101,4 +104,4 @@ export default {
101104 102105 return textResponse('Not found', 404, request.method)103106 },104-} satisfies ExportedHandler<Env>107+} satisfies ExportedHandler<Env & TinyTransformEnv>@@ -0,0 +1,259 @@
1+import { APPEARANCES, materializeSvg, SvgNormalizationError, supportedAppearances, type SvgAppearance } from '../../../shared/svg/index.js'2+import { ENGINE_IDS, type EngineId } from '../../../renderers/shared/engines'3+4+export const TINY_TRANSFORM_PATH = '/transform/tiny'5+export const DEFAULT_RENDER_UNIT_ORIGIN_PATTERN = 'https://{engine}.render.diagram.zip'6+export const DEFAULT_APPEARANCE: SvgAppearance = 'auto-transparent'7+const MAX_BODY_BYTES = 1_048_5768+const MAX_BLOCKS = 329+const MAX_BLOCK_SOURCE_LENGTH = 524_28810+const MAX_TITLE_LENGTH = 20011+const RENDER_TIMEOUT_MS = 20_00012+const RENDER_CONCURRENCY = 613+const MAX_ERROR_DETAIL_LENGTH = 20014+const SIGNATURE_PREFIX = 'sha256='15+16+const 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+])27+28+export interface TinyTransformEnv {29+ TINY_TRANSFORM_SECRET?: string30+ RENDER_UNIT_ORIGIN_PATTERN?: string31+}32+33+export interface TinyBlock {34+ index: number35+ lang: string36+ source: string37+}38+39+export interface TinyArtifact {40+ block: number41+ engine: EngineId42+ type: 'image/svg+xml'43+ body: string44+ appearance: SvgAppearance45+}46+47+export interface TinyBlockError {48+ block: number49+ engine: EngineId50+ error: string51+}52+53+interface TinyTransformRequest {54+ appearance: SvgAppearance55+ metadata: { title: string; description: string }56+ blocks: TinyBlock[]57+}58+59+class TransformError extends Error {60+ constructor(readonly status: number, readonly code: string, message: string) {61+ super(message)62+ this.name = 'TransformError'63+ }64+}65+66+function json(value: unknown, status = 200, headers: Record<string, string> = {}): Response {67+ return Response.json(value, {68+ status,69+ headers: {70+ 'cache-control': 'no-store',71+ 'content-type': 'application/json; charset=utf-8',72+ 'x-content-type-options': 'nosniff',73+ ...headers,74+ },75+ })76+}77+78+function errorResponse(error: unknown): Response {79+ if (error instanceof TransformError) return json({ error: { code: error.code, message: error.message } }, error.status)80+ console.error(JSON.stringify({ message: 'tiny transform error', error: error instanceof Error ? error.message : String(error) }))81+ return json({ error: { code: 'internal_error', message: 'The transform could not be completed.' } }, 500)82+}83+84+export function engineForLang(lang: string): EngineId | undefined {85+ return LANG_ALIASES.get(lang.trim().toLowerCase())86+}87+88+export function renderUnitUrl(engine: EngineId, pattern: string | undefined): string {89+ const base = (pattern === undefined || pattern === '' ? DEFAULT_RENDER_UNIT_ORIGIN_PATTERN : pattern).replaceAll('{engine}', engine)90+ return `${base.replace(/\/+$/, '')}/v1/svg`91+}92+93+async function bodyBytes(request: Request): Promise<Uint8Array> {94+ const length = request.headers.get('content-length')95+ if (length !== null && (!/^\d+$/.test(length) || Number(length) > MAX_BODY_BYTES)) throw new TransformError(413, 'request_too_large', 'Transform request is too large.')96+ if (request.body === null) throw new TransformError(400, 'invalid_request', 'A JSON request body is required.')97+ const reader = request.body.getReader()98+ const chunks: Uint8Array[] = []99+ let total = 0100+ while (true) {101+ const item = await reader.read()102+ if (item.done) break103+ total += item.value.byteLength104+ if (total > MAX_BODY_BYTES) {105+ await reader.cancel('request too large')106+ throw new TransformError(413, 'request_too_large', 'Transform request is too large.')107+ }108+ chunks.push(item.value)109+ }110+ const bytes = new Uint8Array(total)111+ let offset = 0112+ for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength }113+ return bytes114+}115+116+function hexBytes(value: string): Uint8Array | undefined {117+ if (value.length === 0 || value.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(value)) return undefined118+ const bytes = new Uint8Array(value.length / 2)119+ for (let index = 0; index < bytes.length; index += 1) bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16)120+ return bytes121+}122+123+export async function signBody(secret: string, body: BufferSource): Promise<string> {124+ const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])125+ const digest = new Uint8Array(await crypto.subtle.sign('HMAC', key, body))126+ return `${SIGNATURE_PREFIX}${Array.from(digest, byte => byte.toString(16).padStart(2, '0')).join('')}`127+}128+129+async function verifySignature(secret: string, body: Uint8Array, header: string | null): Promise<void> {130+ const presented = header?.trim() ?? ''131+ const provided = presented.toLowerCase().startsWith(SIGNATURE_PREFIX) ? hexBytes(presented.slice(SIGNATURE_PREFIX.length)) : undefined132+ const expected = hexBytes((await signBody(secret, body)).slice(SIGNATURE_PREFIX.length))133+ if (provided === undefined || expected === undefined || provided.byteLength !== expected.byteLength || !crypto.subtle.timingSafeEqual(provided, expected)) {134+ throw new TransformError(401, 'invalid_signature', 'X-Tiny-Signature does not match the request body.')135+ }136+}137+138+function objectValue(value: unknown, name: string): Record<string, unknown> {139+ if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new TransformError(400, 'invalid_request', `${name} must be an object.`)140+ return value as Record<string, unknown>141+}142+143+function eventTitle(event: unknown): string {144+ if (event === undefined) return ''145+ const tags = objectValue(event, 'event').tags146+ if (!Array.isArray(tags)) return ''147+ for (const tag of tags) {148+ if (Array.isArray(tag) && tag[0] === 'title' && typeof tag[1] === 'string') return tag[1].slice(0, MAX_TITLE_LENGTH)149+ }150+ return ''151+}152+153+function parseBlock(value: unknown, position: number): TinyBlock {154+ const block = objectValue(value, `blocks[${position}]`)155+ const index = block.index ?? position156+ if (!Number.isInteger(index) || Number(index) < 0) throw new TransformError(400, 'invalid_request', `blocks[${position}].index must be a non-negative integer.`)157+ if (typeof block.lang !== 'string') throw new TransformError(400, 'invalid_request', `blocks[${position}].lang must be a string.`)158+ if (typeof block.source !== 'string') throw new TransformError(400, 'invalid_request', `blocks[${position}].source must be a string.`)159+ 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.`)160+ return { index: Number(index), lang: block.lang, source: block.source }161+}162+163+function parseTransformRequest(bytes: Uint8Array): TinyTransformRequest {164+ let input: unknown165+ try { input = JSON.parse(new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }).decode(bytes)) } catch {166+ throw new TransformError(400, 'invalid_json', 'Transform request is not valid JSON.')167+ }168+ const body = objectValue(input, 'request')169+ if (!Array.isArray(body.blocks)) throw new TransformError(400, 'invalid_request', 'blocks must be an array.')170+ if (body.blocks.length > MAX_BLOCKS) throw new TransformError(413, 'too_many_blocks', `blocks cannot contain more than ${MAX_BLOCKS} entries.`)171+ const appearance = body.appearance ?? DEFAULT_APPEARANCE172+ if (typeof appearance !== 'string' || !(APPEARANCES as readonly string[]).includes(appearance)) {173+ throw new TransformError(400, 'invalid_appearance', `appearance must be one of ${APPEARANCES.join(', ')}.`)174+ }175+ return {176+ appearance: appearance as SvgAppearance,177+ metadata: { title: eventTitle(body.event), description: '' },178+ blocks: body.blocks.map(parseBlock),179+ }180+}181+182+async function failureDetail(response: Response): Promise<string> {183+ let text = ''184+ try { text = (await response.text()).slice(0, 4_096) } catch { return '' }185+ try {186+ const payload = JSON.parse(text) as { error?: { message?: unknown } }187+ if (typeof payload?.error?.message === 'string') return payload.error.message188+ } catch { /* plain text failure body */ }189+ return text.replace(/\s+/g, ' ').trim().slice(0, MAX_ERROR_DETAIL_LENGTH)190+}191+192+function chooseAppearance(canonical: string, requested: SvgAppearance): SvgAppearance {193+ if (requested === 'raw') return 'raw'194+ return (supportedAppearances(canonical) as readonly string[]).includes(requested) ? requested : 'raw'195+}196+197+async function renderBlock(block: TinyBlock, engine: EngineId, request: TinyTransformRequest, env: TinyTransformEnv): Promise<TinyArtifact | TinyBlockError> {198+ try {199+ const response = await fetch(renderUnitUrl(engine, env.RENDER_UNIT_ORIGIN_PATTERN), {200+ method: 'POST',201+ headers: { accept: 'image/svg+xml', 'content-type': 'application/json' },202+ body: JSON.stringify({203+ source: block.source,204+ format: 'svg',205+ options: {},206+ metadata: request.metadata,207+ presentation: { background: '', padding: 0, frame: false },208+ }),209+ signal: AbortSignal.timeout(RENDER_TIMEOUT_MS),210+ })211+ if (!response.ok) {212+ const detail = await failureDetail(response)213+ return { block: block.index, engine, error: `renderer answered ${response.status}${detail === '' ? '' : `: ${detail}`}` }214+ }215+ const canonical = await response.text()216+ const appearance = chooseAppearance(canonical, request.appearance)217+ return { block: block.index, engine, type: 'image/svg+xml', body: materializeSvg(canonical, appearance), appearance }218+ } catch (error) {219+ if (error instanceof SvgNormalizationError) return { block: block.index, engine, error: `renderer output rejected: ${error.message}` }220+ if (error instanceof Error && error.name === 'TimeoutError') return { block: block.index, engine, error: `renderer timed out after ${RENDER_TIMEOUT_MS / 1_000} seconds` }221+ return { block: block.index, engine, error: `renderer unreachable: ${error instanceof Error ? error.message : String(error)}` }222+ }223+}224+225+async function renderAll(request: TinyTransformRequest, env: TinyTransformEnv): Promise<Array<TinyArtifact | TinyBlockError>> {226+ const jobs = request.blocks.flatMap(block => {227+ const engine = engineForLang(block.lang)228+ return engine === undefined ? [] : [{ block, engine }]229+ })230+ const results = new Array<TinyArtifact | TinyBlockError>(jobs.length)231+ let next = 0232+ const worker = async (): Promise<void> => {233+ while (next < jobs.length) {234+ const position = next235+ next += 1236+ const job = jobs[position]237+ results[position] = await renderBlock(job.block, job.engine, request, env)238+ }239+ }240+ await Promise.all(Array.from({ length: Math.min(RENDER_CONCURRENCY, jobs.length) }, worker))241+ return results242+}243+244+export async function tinyTransform(request: Request, env: TinyTransformEnv): Promise<Response> {245+ try {246+ if (request.method !== 'POST') return json({ error: { code: 'method_not_allowed', message: 'Use POST for the transform endpoint.' } }, 405, { allow: 'POST' })247+ const secret = env.TINY_TRANSFORM_SECRET248+ if (typeof secret !== 'string' || secret === '') return json({ error: { code: 'transform_unavailable', message: 'TINY_TRANSFORM_SECRET is not configured.' } }, 503)249+ const bytes = await bodyBytes(request)250+ await verifySignature(secret, bytes, request.headers.get('x-tiny-signature'))251+ const results = await renderAll(parseTransformRequest(bytes), env)252+ return json({253+ artifacts: results.filter((result): result is TinyArtifact => 'body' in result),254+ errors: results.filter((result): result is TinyBlockError => 'error' in result),255+ })256+ } catch (error) {257+ return errorResponse(error)258+ }259+}