clone

a browser-first diagram editor with live svg previews, local drafts and encrypted, accountless sharing

main
diagramzip / renderers / shared / browser-unit.ts
browser-unit.ts 12.2K
1import { canonicalizeSvg, NORMALIZER_BUILD, SvgNormalizationError, normalizationFor } from '../../shared/svg/index.js'23const CACHE_MAX_AGE = 1_8004const CACHE_SCHEMA = '1'5const MAX_REQUEST_BYTES = 1_048_5766const MAX_SOURCE_LENGTH = 524_2887const MAX_OPTION_COUNT = 648const EXPOSED_HEADERS = [9 'Cache-Control', 'Content-Type', 'X-Diagram-Cache', 'X-Diagram-Engine',10 'X-Diagram-Engine-Version', 'X-Diagram-Pipeline', 'X-Diagram-Renderer',11 'X-Diagram-Unit', 'X-Renderer-Build',12].join(', ')1314export interface BrowserUnitDescriptor {15 readonly id: string16 readonly kind: 'render' | 'translate'17 readonly version: string18 readonly build: string19 readonly pipeline: readonly string[]20 readonly frame: string21 readonly knownLosses: readonly string[]22 readonly license?: string23 readonly licenses?: string24 readonly source?: string25}2627interface RenderRequest {28 engine: string29 source: string30 format: 'svg'31 options: Record<string, string>32 metadata: { title: string; description: string }33 presentation: { background: string; padding: number; frame: boolean }34}3536interface BrowserRendererBinding {37 fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>38}3940interface BrowserUnitEnv {41 ASSETS: Fetcher42 BROWSER_RENDERER: BrowserRendererBinding43}4445class UnitError extends Error {46 constructor(readonly status: number, readonly code: string, message: string) {47 super(message)48 this.name = 'UnitError'49 }50}5152function json(value: unknown, init: ResponseInit = {}): Response {53 const headers = new Headers(init.headers)54 headers.set('Access-Control-Allow-Origin', '*')55 headers.set('Access-Control-Expose-Headers', EXPOSED_HEADERS)56 headers.set('Cache-Control', 'no-store')57 headers.set('Content-Type', 'application/json; charset=utf-8')58 headers.set('X-Content-Type-Options', 'nosniff')59 return Response.json(value, { ...init, headers })60}6162function errorResponse(error: unknown): Response {63 if (error instanceof UnitError) return json({ error: { code: error.code, message: error.message } }, { status: error.status })64 if (error instanceof SvgNormalizationError) return json({ error: { code: error.code, message: error.message } }, { status: error.status })65 return json({ error: { code: 'internal_error', message: 'The diagram could not be rendered.' } }, { status: 500 })66}6768function objectValue(value: unknown, name: string): Record<string, unknown> {69 if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new UnitError(400, 'invalid_request', `${name} must be an object.`)70 return value as Record<string, unknown>71}7273function boundedString(value: unknown, name: string, maximum: number): string {74 if (typeof value !== 'string') throw new UnitError(400, 'invalid_request', `${name} must be a string.`)75 if (value.length > maximum) throw new UnitError(413, 'request_too_large', `${name} is too large.`)76 return value77}7879async function bodyText(request: Request): Promise<string> {80 const length = request.headers.get('Content-Length')81 if (length !== null && (!/^\d+$/.test(length) || Number(length) > MAX_REQUEST_BYTES)) throw new UnitError(413, 'request_too_large', 'Render request is too large.')82 if (request.body === null) throw new UnitError(400, 'invalid_request', 'A JSON request body is required.')83 const reader = request.body.getReader()84 const chunks: Uint8Array[] = []85 let total = 086 while (true) {87 const item = await reader.read()88 if (item.done) break89 total += item.value.byteLength90 if (total > MAX_REQUEST_BYTES) {91 await reader.cancel('request too large')92 throw new UnitError(413, 'request_too_large', 'Render request is too large.')93 }94 chunks.push(item.value)95 }96 const bytes = new Uint8Array(total)97 let offset = 098 for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength }99 return new TextDecoder().decode(bytes)100}101102function optionsValue(value: unknown): Record<string, string> {103 const input = value === undefined ? {} : objectValue(value, 'options')104 const entries = Object.entries(input)105 if (entries.length > MAX_OPTION_COUNT) throw new UnitError(400, 'invalid_options', `options cannot contain more than ${MAX_OPTION_COUNT} entries.`)106 const output: Record<string, string> = {}107 for (const [name, option] of entries) {108 if (!/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(name) || !['string', 'number', 'boolean'].includes(typeof option)) throw new UnitError(400, 'invalid_options', `Invalid renderer option: ${name}.`)109 const normalized = String(option)110 if (normalized.length > 4_096) throw new UnitError(400, 'invalid_options', `Renderer option ${name} is too large.`)111 output[name.toLowerCase()] = normalized112 }113 return output114}115116function parseRequest(input: Record<string, unknown>, engine: string): RenderRequest {117 if (input.engine !== undefined) throw new UnitError(400, 'invalid_request', 'Renderer unit requests must not select an engine.')118 const source = boundedString(input.source, 'source', MAX_SOURCE_LENGTH)119 if (source.trim() === '') throw new UnitError(400, 'empty_source', 'Diagram source cannot be empty.')120 if (input.format !== undefined && input.format !== 'svg') throw new UnitError(400, 'unsupported_format', 'The v2 rendering plane currently supports SVG only.')121 const metadataInput = input.metadata === undefined ? {} : objectValue(input.metadata, 'metadata')122 const presentationInput = input.presentation === undefined ? {} : objectValue(input.presentation, 'presentation')123 const title = boundedString(metadataInput.title ?? '', 'metadata.title', 200)124 const description = boundedString(metadataInput.description ?? '', 'metadata.description', 2_000)125 const background = boundedString(presentationInput.background ?? '', 'presentation.background', 7)126 const padding = presentationInput.padding ?? 0127 const frame = presentationInput.frame ?? false128 if (background !== '' && !/^#[0-9a-f]{6}$/i.test(background)) throw new UnitError(400, 'invalid_presentation', 'presentation.background must be an RGB hex color.')129 if (!Number.isInteger(padding) || Number(padding) < 0 || Number(padding) > 256) throw new UnitError(400, 'invalid_presentation', 'presentation.padding must be an integer from 0 to 256.')130 if (typeof frame !== 'boolean') throw new UnitError(400, 'invalid_presentation', 'presentation.frame must be a boolean.')131 return { engine, source, format: 'svg', options: optionsValue(input.options), metadata: { title, description }, presentation: { background, padding: Number(padding), frame } }132}133134async function parseUnitRequest(request: Request, engine: string): Promise<RenderRequest> {135 if (!request.headers.get('Content-Type')?.toLowerCase().startsWith('application/json')) throw new UnitError(415, 'unsupported_media_type', 'Render requests must use application/json.')136 let input: unknown137 try { input = JSON.parse(await bodyText(request)) } catch (error) {138 if (error instanceof UnitError) throw error139 throw new UnitError(400, 'invalid_json', 'Render request is not valid JSON.')140 }141 return parseRequest(objectValue(input, 'request'), engine)142}143144function canonicalRequest(request: RenderRequest): string {145 const { presentation: _presentation, ...canonical } = request146 return JSON.stringify({ ...canonical, options: Object.fromEntries(Object.entries(request.options).sort(([a], [b]) => a.localeCompare(b))) })147}148149export async function cacheKey(request: RenderRequest, descriptor: BrowserUnitDescriptor): Promise<Request> {150 const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonicalRequest(request)))151 let binary = ''152 for (const byte of new Uint8Array(digest)) binary += String.fromCharCode(byte)153 const encoded = btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '')154 return new Request(`https://diagramzip-browser-unit-cache.invalid/${CACHE_SCHEMA}/${NORMALIZER_BUILD}/${descriptor.id}/${descriptor.build}/${encoded}`)155}156157function identityHeaders(descriptor: BrowserUnitDescriptor, cache: 'HIT' | 'MISS'): Headers {158 const headers = new Headers({159 'Access-Control-Allow-Origin': '*', 'Access-Control-Expose-Headers': EXPOSED_HEADERS,160 'Cache-Control': `public, max-age=${CACHE_MAX_AGE}`, 'Content-Type': 'image/svg+xml; charset=utf-8',161 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox",162 'X-Content-Type-Options': 'nosniff', 'X-Diagram-Cache': cache, 'X-Diagram-Engine': descriptor.id,163 'X-Diagram-Engine-Version': descriptor.version, 'X-Diagram-Renderer': 'browser-run',164 'X-Diagram-Unit': descriptor.id, 'X-Diagram-Pipeline': descriptor.pipeline.join(','), 'X-Renderer-Build': descriptor.build,165 })166 return headers167}168169async function render(request: RenderRequest, descriptor: BrowserUnitDescriptor, env: BrowserUnitEnv): Promise<string> {170 const requestId = crypto.randomUUID()171 const response = await env.BROWSER_RENDERER.fetch('https://browser-run.internal/render', {172 method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Diagram-Request-Id': requestId },173 body: JSON.stringify({ engine: descriptor.id, source: request.source, requestId }),174 })175 if (!response.ok) {176 const status = response.status >= 400 && response.status < 500 ? response.status : 502177 throw new UnitError(status, status === 413 ? 'request_too_large' : 'render_failed', status === 413 ? 'Render request is too large.' : 'The browser renderer could not render the diagram.')178 }179 let result: unknown180 try { result = await response.json() } catch { throw new UnitError(502, 'render_failed', 'The browser renderer returned an invalid response.') }181 const payload = objectValue(result, 'browser renderer response')182 if (typeof payload.version !== 'string' || payload.version.length === 0 || typeof payload.build !== 'string' || payload.build.length === 0 || !Array.isArray(payload.pipeline) || payload.pipeline.some(value => typeof value !== 'string') || payload.pipeline[0] !== descriptor.id) {183 throw new UnitError(502, 'render_failed', 'The browser renderer returned an invalid result.')184 }185 const svg = payload.svg186 if (typeof svg !== 'string' || svg.length === 0) throw new UnitError(502, 'render_failed', 'The browser renderer returned no SVG.')187 return canonicalizeSvg(svg, request.metadata, descriptor.id, descriptor.version)188}189190export function createBrowserRendererUnit(descriptor: BrowserUnitDescriptor): ExportedHandler<BrowserUnitEnv> {191 return {192 async fetch(request, env, ctx): Promise<Response> {193 const url = new URL(request.url)194 if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: { 'Access-Control-Allow-Headers': 'Accept, Content-Type', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Origin': '*', 'Access-Control-Max-Age': '86400' } })195 try {196 if (request.method === 'GET' && url.pathname === '/v1/health') return json({ ok: true, unit: descriptor.id, id: descriptor.id })197 if (request.method === 'GET' && url.pathname === '/v1/capabilities') return json({ ...descriptor, unit: descriptor.id, format: 'svg', runtime: 'browser-run', normalization: normalizationFor(descriptor.id, descriptor.version) }, { headers: { 'Cache-Control': 'public, max-age=300' } })198 if (request.method !== 'POST' || url.pathname !== '/v1/svg') {199 if (url.pathname.startsWith('/v1/')) return json({ error: { code: 'not_found', message: 'Route not found.' } }, { status: 404 })200 return env.ASSETS.fetch(request)201 }202 const input = await parseUnitRequest(request, descriptor.id)203 const key = await cacheKey(input, descriptor)204 const cached = await caches.default.match(key)205 if (cached !== undefined) {206 const headers = new Headers(cached.headers); headers.set('X-Diagram-Cache', 'HIT')207 return new Response(cached.body, { status: cached.status, headers })208 }209 const body = await render(input, descriptor, env)210 const response = new Response(body, { headers: identityHeaders(descriptor, 'MISS') })211 ctx.waitUntil(caches.default.put(key, response.clone()))212 return response213 } catch (error) {214 if (!(error instanceof UnitError || error instanceof SvgNormalizationError)) console.error(JSON.stringify({ message: 'browser renderer unit error', unit: descriptor.id, error: error instanceof Error ? error.message : String(error) }))215 return errorResponse(error)216 }217 },218 }219}