1import { RenderError, RequestError } from './errors'2import { canonicalizeSvg } from './svg'3import type { EngineId, RendererAdapter, RenderRequest } from './types'4import { parseUnitRenderRequest } from './validation'5import { NORMALIZER_BUILD, normalizationFor } from '../../../../shared/svg/index.js'67const CACHE_MAX_AGE = 1_8008const CACHE_SCHEMA = '2'9const EXPOSED_RESPONSE_HEADERS = [10 'Cache-Control',11 'Content-Type',12 'X-Diagram-Cache',13 'X-Diagram-Engine',14 'X-Diagram-Engine-Version',15 'X-Diagram-Pipeline',16 'X-Diagram-Renderer',17 'X-Diagram-Unit',18 'X-Renderer-Build',19].join(', ')2021export interface RendererUnitDescriptor {22 readonly id: EngineId23 readonly kind: 'render' | 'translate'24 readonly adapter: RendererAdapter25 readonly pipeline?: readonly EngineId[]26 readonly knownLosses?: readonly string[]27}2829interface RendererUnitEnv {30 RENDERER_BUILD: string31}3233function json(value: unknown, init: ResponseInit = {}): Response {34 const headers = new Headers(init.headers)35 headers.set('Content-Type', 'application/json; charset=utf-8')36 headers.set('Cache-Control', 'no-store')37 headers.set('Access-Control-Allow-Origin', '*')38 return Response.json(value, { ...init, headers })39}4041function errorResponse(error: unknown): Response {42 if (error instanceof RequestError || error instanceof RenderError) {43 return json({ error: { code: error.code, message: error.message } }, { status: error.status })44 }45 return json({ error: { code: 'internal_error', message: 'The diagram could not be rendered.' } }, { status: 500 })46}4748function canonicalRequest(request: RenderRequest): string {49 const options = Object.fromEntries(Object.entries(request.options).sort(([left], [right]) => left.localeCompare(right)))50 const { presentation: _presentation, ...canonical } = request51 return JSON.stringify({ ...canonical, options })52}5354function base64Url(bytes: Uint8Array): string {55 let binary = ''56 for (const byte of bytes) binary += String.fromCharCode(byte)57 return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '')58}5960export async function cacheRequestFor(request: RenderRequest, build: string): Promise<Request> {61 const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonicalRequest(request)))62 return new Request(`https://diagramzip-unit-cache.invalid/${CACHE_SCHEMA}/${encodeURIComponent(NORMALIZER_BUILD)}/${encodeURIComponent(request.engine)}/${encodeURIComponent(build)}/${base64Url(new Uint8Array(digest))}`)63}6465function responseWithCacheStatus(response: Response, status: 'HIT' | 'MISS'): Response {66 const headers = new Headers(response.headers)67 headers.set('X-Diagram-Cache', status)68 return new Response(response.body, { status: response.status, statusText: response.statusText, headers })69}7071async function render(72 unitId: string,73 descriptor: RendererUnitDescriptor,74 request: RenderRequest,75 build: string,76 signal: AbortSignal,77): Promise<Response> {78 const rendered = await descriptor.adapter.render(request, signal)79 if (signal.aborted) throw signal.reason80 const body = canonicalizeSvg(rendered.body, request.metadata, descriptor.id, rendered.engineVersion)81 return new Response(body, {82 headers: {83 'Access-Control-Allow-Origin': '*',84 'Access-Control-Expose-Headers': EXPOSED_RESPONSE_HEADERS,85 'Cache-Control': `public, max-age=${CACHE_MAX_AGE}`,86 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox",87 'Content-Type': 'image/svg+xml; charset=utf-8',88 'X-Content-Type-Options': 'nosniff',89 'X-Diagram-Engine': descriptor.id,90 'X-Diagram-Engine-Version': rendered.engineVersion,91 'X-Diagram-Renderer': rendered.runtime,92 'X-Diagram-Unit': unitId,93 'X-Diagram-Pipeline': [unitId, ...(descriptor.pipeline ?? [])].join(','),94 'X-Renderer-Build': build,95 },96 })97}9899function capabilities(unitId: string, descriptor: RendererUnitDescriptor, build: string): Response {100 return json({101 unit: unitId,102 id: descriptor.id,103 kind: descriptor.kind,104 format: 'svg',105 runtime: descriptor.adapter.runtime,106 version: descriptor.adapter.version,107 build,108 pipeline: [unitId, ...(descriptor.pipeline ?? [])],109 knownLosses: descriptor.knownLosses ?? [],110 normalization: normalizationFor(descriptor.id, descriptor.adapter.version),111 }, { headers: { 'Cache-Control': 'public, max-age=300' } })112}113114export function createRendererUnit(descriptor: RendererUnitDescriptor): ExportedHandler<RendererUnitEnv> {115 return createRendererUnitGroup(descriptor.id, [descriptor])116}117118function engineFromHostname(hostname: string): string {119 return hostname.split('.')[0]?.toLowerCase() ?? ''120}121122export function createRendererUnitGroup(123 unitId: string,124 descriptors: readonly RendererUnitDescriptor[],125): ExportedHandler<RendererUnitEnv> {126 if (descriptors.length === 0) throw new Error('A renderer unit group must contain at least one engine.')127 const byEngine = new Map<EngineId, RendererUnitDescriptor>()128 for (const descriptor of descriptors) {129 if (descriptor.adapter.id !== descriptor.id) throw new Error('Renderer adapter identity does not match its engine identity.')130 if (byEngine.has(descriptor.id)) throw new Error(`Renderer engine ${descriptor.id} is declared more than once.`)131 byEngine.set(descriptor.id, descriptor)132 }133134 return {135 async fetch(request, env, ctx): Promise<Response> {136 const url = new URL(request.url)137 const requestedEngine = engineFromHostname(url.hostname)138 if (request.method === 'OPTIONS') {139 return new Response(null, {140 status: 204,141 headers: {142 'Access-Control-Allow-Headers': 'Accept, Content-Type',143 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',144 'Access-Control-Allow-Origin': '*',145 'Access-Control-Max-Age': '86400',146 },147 })148 }149 try {150 const descriptor = byEngine.get(requestedEngine as EngineId)151 if (descriptor === undefined) {152 return json({ error: { code: 'not_found', message: 'This renderer unit does not serve the requested engine.' } }, { status: 404 })153 }154 if (request.method === 'GET' && url.pathname === '/v1/health') return json({ ok: true, unit: unitId, id: descriptor.id })155 if (request.method === 'GET' && url.pathname === '/v1/capabilities') return capabilities(unitId, descriptor, env.RENDERER_BUILD)156 if (request.method !== 'POST' || url.pathname !== '/v1/svg') {157 return json({ error: { code: 'not_found', message: 'Route not found.' } }, { status: 404 })158 }159 const input = await parseUnitRenderRequest(request, descriptor.id)160 const cacheKey = await cacheRequestFor(input, env.RENDERER_BUILD)161 const cached = await caches.default.match(cacheKey)162 if (cached !== undefined) return responseWithCacheStatus(cached, 'HIT')163 const response = await render(unitId, descriptor, input, env.RENDERER_BUILD, request.signal)164 ctx.waitUntil(caches.default.put(cacheKey, response.clone()))165 return responseWithCacheStatus(response, 'MISS')166 } catch (error) {167 if (!(error instanceof RequestError || error instanceof RenderError)) {168 console.error(JSON.stringify({169 message: 'unhandled renderer unit error',170 unit: unitId,171 engine: requestedEngine,172 error: error instanceof Error ? error.message : String(error),173 path: url.pathname,174 }))175 }176 return errorResponse(error)177 }178 },179 }180}