clone

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

feat: expose browser-backed renderer HTTP units

Dami committed 02991b8
Give the five former client-only hostnames a shared /v1/svg contract backed by the private Browser Run service. Preserve static frames, enforce hostname-owned engine selection and request bounds, canonicalize SVG, cache results, publish browser-run identity, and update catalog and production smoke expectations.
commit
02991b85b1609dda412c9ce5abfd808ac56db1ba
parent
2e8ff8a138a78bf85255c7e3683b0cdd3060da8b
author
Dami <dami.etoile@gmail.com>
16 files changed, +349 -198
Mrenderers/catalog/src/catalog.ts24++--
Mrenderers/catalog/test/catalog.test.ts4+-
Mrenderers/client/bpmn/unit/worker.js34+----
Mrenderers/client/bpmn/wrangler.jsonc1+
Mrenderers/client/diagramsnet/unit/worker.js36+-----
Mrenderers/client/diagramsnet/wrangler.jsonc1+
Mrenderers/client/excalidraw/unit/worker.js35+-----
Mrenderers/client/excalidraw/wrangler.jsonc1+
Mrenderers/client/mermaid/unit/worker.js34+----
Mrenderers/client/mermaid/wrangler.jsonc1+
Mrenderers/client/tikz/unit/worker.js37+-----
Mrenderers/client/tikz/wrangler.jsonc1+
Arenderers/edge/test/browser-unit.test.ts96+++++++++++++
Mrenderers/scripts/smoke-units.mjs21+--
Arenderers/shared/browser-unit.ts219++++++++++++++++++++++++++++++
Mrenderers/shared/engines.ts2+-
M renderers/catalog/src/catalog.ts +12 -12
@@ -12,7 +12,7 @@ export interface EngineCatalogEntry {
1212 1313 const targetRuntime: Record<EngineId, EngineRuntime> = {1414 plantuml: 'edge-wasm',15- mermaid: 'client',15+ mermaid: 'browser-run',1616 graphviz: 'edge-wasm',1717 d2: 'edge-wasm',1818 c4plantuml: 'edge-wasm',
@@ -22,20 +22,20 @@ const targetRuntime: Record<EngineId, EngineRuntime> = {
2222 nwdiag: 'edge-python',2323 packetdiag: 'edge-python',2424 rackdiag: 'edge-python',25- bpmn: 'client',25+ bpmn: 'browser-run',2626 bytefield: 'edge-js',2727 dbml: 'edge-wasm',28- diagramsnet: 'client',28+ diagramsnet: 'browser-run',2929 ditaa: 'edge-wasm',3030 erd: 'edge-wasm',31- excalidraw: 'client',31+ excalidraw: 'browser-run',3232 goat: 'edge-wasm',3333 nomnoml: 'edge-js',3434 pikchr: 'edge-wasm',3535 structurizr: 'edge-js',3636 svgbob: 'edge-wasm',3737 symbolator: 'edge-python',38- tikz: 'client',38+ tikz: 'browser-run',3939 umlet: 'edge-js',4040 vega: 'edge-js',4141 vegalite: 'edge-js',
@@ -46,11 +46,11 @@ const targetRuntime: Record<EngineId, EngineRuntime> = {
4646 const activeRuntime: Record<EngineId, EngineRuntime> = {4747 plantuml: 'edge-wasm',4848 c4plantuml: 'edge-wasm',49- mermaid: 'client',50- bpmn: 'client',49+ mermaid: 'browser-run',50+ bpmn: 'browser-run',5151 bytefield: 'edge-js',52- diagramsnet: 'client',53- excalidraw: 'client',52+ diagramsnet: 'browser-run',53+ excalidraw: 'browser-run',5454 nomnoml: 'edge-js',5555 vega: 'edge-js',5656 vegalite: 'edge-js',
@@ -72,7 +72,7 @@ const activeRuntime: Record<EngineId, EngineRuntime> = {
7272 d2: 'edge-wasm',7373 ditaa: 'edge-wasm',7474 symbolator: 'edge-python',75- tikz: 'client',75+ tikz: 'browser-run',7676 umlet: 'edge-js',7777 }7878
@@ -81,7 +81,7 @@ const versions: Record<EngineId, string> = {
8181 c4plantuml: 'c4plantuml@2.7.0-lowered+plantuml@1.2026.6/edge-wasm-1',8282 mermaid: 'mermaid@11.17.0',8383 bpmn: 'bpmn-js@18.25.1',84- diagramsnet: 'diagrams.net@29.6.1/client-unit-1',84+ diagramsnet: 'diagrams.net@29.6.1',8585 bytefield: 'bytefield-svg@1.11.0',8686 excalidraw: '@excalidraw/excalidraw@0.18.1',8787 nomnoml: 'nomnoml@1.7.0',
@@ -105,7 +105,7 @@ const versions: Record<EngineId, string> = {
105105 d2: 'd2@0.7.1/dagre-wasm-1',106106 ditaa: 'ditaa-ascii+svgbob@0.7.6/edge-wasm-1',107107 symbolator: 'symbolator@1.2.2/python-translation-1',108- tikz: '@planktimerr/tikzjax@1.0.63/client-unit-1',108+ tikz: '@planktimerr/tikzjax@1.0.63',109109 umlet: 'diagramzip-umlet-svg@1',110110 }111111
M renderers/catalog/test/catalog.test.ts +2 -2
M renderers/client/bpmn/unit/worker.js +2 -32
@@ -1,33 +1,3 @@
1-const CAPABILITIES = Object.freeze({2- id: 'bpmn',3- kind: 'render',4- format: 'svg',5- runtime: 'client',6- version: 'bpmn-js@18.25.1',7- build: 'bpmn-js-18.25.1-client-unit-1',8- pipeline: ['bpmn'],9- frame: '/index.html?v=1',10- knownLosses: [11- 'External links and resource-loading elements are removed from exported SVG.',12- ],13-})1+import { createBrowserRendererUnit } from '../../../shared/browser-unit.ts'142 15-function json(value, init = {}) {16- const headers = new Headers(init.headers)17- headers.set('Access-Control-Allow-Origin', '*')18- headers.set('Cache-Control', 'public, max-age=300')19- headers.set('Content-Type', 'application/json; charset=utf-8')20- return Response.json(value, { ...init, headers })21-}22-23-export default {24- fetch(request, env) {25- const url = new URL(request.url)26- if (request.method === 'GET' && url.pathname === '/v1/health') return json({ ok: true, id: CAPABILITIES.id })27- if (request.method === 'GET' && url.pathname === '/v1/capabilities') return json(CAPABILITIES)28- if (url.pathname.startsWith('/v1/')) {29- return json({ error: { code: 'not_found', message: 'Route not found.' } }, { status: 404 })30- }31- return env.ASSETS.fetch(request)32- },33-}3+export default createBrowserRendererUnit({ id: 'bpmn', kind: 'render', version: 'bpmn-js@18.25.1', build: 'bpmn-js-18.25.1-browser-run-unit-1', pipeline: ['bpmn'], frame: '/index.html?v=1', knownLosses: ['External links and resource-loading elements are removed from exported SVG.'] })
M renderers/client/bpmn/wrangler.jsonc +1 -0
M renderers/client/diagramsnet/unit/worker.js +2 -34
@@ -1,35 +1,3 @@
1-const CAPABILITIES = Object.freeze({2- id: 'diagramsnet',3- kind: 'render',4- format: 'svg',5- runtime: 'client',6- version: 'diagrams.net@29.6.1',7- build: 'diagramsnet-29.6.1-client-unit-1',8- pipeline: ['diagramsnet'],9- frame: '/index.html?v=1',10- license: 'Apache-2.0 AND LicenseRef-diagrams-net-assets',11- licenses: '/licenses/',12- source: '/SOURCE.md',13- knownLosses: [14- 'Rendering requires the sandboxed browser exporter; there is no server-side rendering fallback.',15- 'External resources are blocked; embedded data images remain supported.',16- ],17-})1+import { createBrowserRendererUnit } from '../../../shared/browser-unit.ts'182 19-function json(value, init = {}) {20- const headers = new Headers(init.headers)21- headers.set('Access-Control-Allow-Origin', '*')22- headers.set('Cache-Control', 'public, max-age=300')23- headers.set('Content-Type', 'application/json; charset=utf-8')24- return Response.json(value, { ...init, headers })25-}26-27-export default {28- fetch(request, env) {29- const url = new URL(request.url)30- if (request.method === 'GET' && url.pathname === '/v1/health') return json({ ok: true, id: CAPABILITIES.id })31- if (request.method === 'GET' && url.pathname === '/v1/capabilities') return json(CAPABILITIES)32- if (url.pathname.startsWith('/v1/')) return json({ error: { code: 'not_found', message: 'Route not found.' } }, { status: 404 })33- return env.ASSETS.fetch(request)34- },35-}3+export default createBrowserRendererUnit({ id: 'diagramsnet', kind: 'render', version: 'diagrams.net@29.6.1', build: 'diagramsnet-29.6.1-browser-run-unit-1', pipeline: ['diagramsnet'], frame: '/index.html?v=1', license: 'Apache-2.0 AND LicenseRef-diagrams-net-assets', licenses: '/licenses/', source: '/SOURCE.md', knownLosses: ['Rendering requires the sandboxed browser exporter; there is no server-side rendering fallback.', 'External resources are blocked; embedded data images remain supported.'] })
M renderers/client/diagramsnet/wrangler.jsonc +1 -0
M renderers/client/excalidraw/unit/worker.js +2 -33
@@ -1,34 +1,3 @@
1-const CAPABILITIES = Object.freeze({2- id: 'excalidraw',3- kind: 'render',4- format: 'svg',5- runtime: 'client',6- version: '@excalidraw/excalidraw@0.18.1',7- build: 'excalidraw-0.18.1-client-unit-2',8- pipeline: ['excalidraw'],9- frame: '/index.html?v=4',10- knownLosses: [11- 'External resources are blocked; image data must be embedded in the scene.',12- 'Font subsetting is disabled by the sandbox policy, so exports embed the complete self-hosted font.',13- ],14-})1+import { createBrowserRendererUnit } from '../../../shared/browser-unit.ts'152 16-function json(value, init = {}) {17- const headers = new Headers(init.headers)18- headers.set('Access-Control-Allow-Origin', '*')19- headers.set('Cache-Control', 'public, max-age=300')20- headers.set('Content-Type', 'application/json; charset=utf-8')21- return Response.json(value, { ...init, headers })22-}23-24-export default {25- fetch(request, env) {26- const url = new URL(request.url)27- if (request.method === 'GET' && url.pathname === '/v1/health') return json({ ok: true, id: CAPABILITIES.id })28- if (request.method === 'GET' && url.pathname === '/v1/capabilities') return json(CAPABILITIES)29- if (url.pathname.startsWith('/v1/')) {30- return json({ error: { code: 'not_found', message: 'Route not found.' } }, { status: 404 })31- }32- return env.ASSETS.fetch(request)33- },34-}3+export default createBrowserRendererUnit({ id: 'excalidraw', kind: 'render', version: '@excalidraw/excalidraw@0.18.1', build: 'excalidraw-0.18.1-browser-run-unit-2', pipeline: ['excalidraw'], frame: '/index.html?v=4', knownLosses: ['External resources are blocked; image data must be embedded in the scene.', 'Font subsetting is disabled by the sandbox policy, so exports embed the complete self-hosted font.'] })
M renderers/client/excalidraw/wrangler.jsonc +1 -0
M renderers/client/mermaid/unit/worker.js +2 -32
@@ -1,33 +1,3 @@
1-const CAPABILITIES = Object.freeze({2- id: 'mermaid',3- kind: 'render',4- format: 'svg',5- runtime: 'client',6- version: 'mermaid@11.17.0',7- build: 'mermaid-11.17.0-client-unit-1',8- pipeline: ['mermaid'],9- frame: '/index.html?v=1',10- knownLosses: [11- 'External links and resource-loading elements are removed; XHTML labels retain only safe text formatting.',12- ],13-})1+import { createBrowserRendererUnit } from '../../../shared/browser-unit.ts'142 15-function json(value, init = {}) {16- const headers = new Headers(init.headers)17- headers.set('Access-Control-Allow-Origin', '*')18- headers.set('Cache-Control', 'public, max-age=300')19- headers.set('Content-Type', 'application/json; charset=utf-8')20- return Response.json(value, { ...init, headers })21-}22-23-export default {24- fetch(request, env) {25- const url = new URL(request.url)26- if (request.method === 'GET' && url.pathname === '/v1/health') return json({ ok: true, id: CAPABILITIES.id })27- if (request.method === 'GET' && url.pathname === '/v1/capabilities') return json(CAPABILITIES)28- if (url.pathname.startsWith('/v1/')) {29- return json({ error: { code: 'not_found', message: 'Route not found.' } }, { status: 404 })30- }31- return env.ASSETS.fetch(request)32- },33-}3+export default createBrowserRendererUnit({ id: 'mermaid', kind: 'render', version: 'mermaid@11.17.0', build: 'mermaid-11.17.0-browser-run-unit-1', pipeline: ['mermaid'], frame: '/index.html?v=1', knownLosses: ['External links and resource-loading elements are removed; XHTML labels retain only safe text formatting.'] })
M renderers/client/mermaid/wrangler.jsonc +1 -0
M renderers/client/tikz/unit/worker.js +2 -35
@@ -1,36 +1,3 @@
1-const CAPABILITIES = Object.freeze({2- id: 'tikz',3- kind: 'render',4- format: 'svg',5- runtime: 'client',6- version: '@planktimerr/tikzjax@1.0.63',7- build: 'tikzjax-1.0.63-client-unit-4',8- pipeline: ['tikz'],9- frame: '/index.html?v=2',10- license: 'GPL-3.0-or-later',11- licenses: '/licenses/',12- source: '/SOURCE.md',13- knownLosses: [14- 'The browser unit uses the bundled TeX/PGF package set, not the full TeX Live installation.',15- 'Layout, fonts, and SVG details can differ from the native dvisvgm renderer.',16- 'External files, shell escape, external links, and resource loads are unavailable.',17- ],18-})1+import { createBrowserRendererUnit } from '../../../shared/browser-unit.ts'192 20-function json(value, init = {}) {21- const headers = new Headers(init.headers)22- headers.set('Access-Control-Allow-Origin', '*')23- headers.set('Cache-Control', 'public, max-age=300')24- headers.set('Content-Type', 'application/json; charset=utf-8')25- return Response.json(value, { ...init, headers })26-}27-28-export default {29- fetch(request, env) {30- const url = new URL(request.url)31- if (request.method === 'GET' && url.pathname === '/v1/health') return json({ ok: true, id: CAPABILITIES.id })32- if (request.method === 'GET' && url.pathname === '/v1/capabilities') return json(CAPABILITIES)33- if (url.pathname.startsWith('/v1/')) return json({ error: { code: 'not_found', message: 'Route not found.' } }, { status: 404 })34- return env.ASSETS.fetch(request)35- },36-}3+export default createBrowserRendererUnit({ id: 'tikz', kind: 'render', version: '@planktimerr/tikzjax@1.0.63', build: 'tikzjax-1.0.63-browser-run-unit-4', pipeline: ['tikz'], frame: '/index.html?v=2', license: 'GPL-3.0-or-later', licenses: '/licenses/', source: '/SOURCE.md', knownLosses: ['The browser unit uses the bundled TeX/PGF package set, not the full TeX Live installation.', 'Layout, fonts, and SVG details can differ from the native dvisvgm renderer.', 'External files, shell escape, external links, and resource loads are unavailable.'] })
M renderers/client/tikz/wrangler.jsonc +1 -0
A renderers/edge/test/browser-unit.test.ts +96 -0
@@ -0,0 +1,96 @@
1+import { describe, expect, it } from 'vitest'2+import { createBrowserRendererUnit } from '../../shared/browser-unit'3+4+const unit = createBrowserRendererUnit({5+ id: 'mermaid',6+ kind: 'render',7+ version: 'mermaid@11.17.0',8+ build: 'mermaid-test-browser-run-1',9+ pipeline: ['mermaid'],10+ frame: '/index.html?v=1',11+ knownLosses: [],12+})13+const fetchUnit = unit.fetch!14+const context = {15+ waitUntil() {},16+ passThroughOnException() {},17+ props: {},18+} as unknown as ExecutionContext19+20+function env(serviceFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>) {21+ return {22+ ASSETS: {23+ fetch: async () => new Response('<!doctype html><title>renderer frame</title>', { headers: { 'Content-Type': 'text/html' } }),24+ },25+ BROWSER_RENDERER: {26+ fetch: serviceFetch ?? (async (input: RequestInfo | URL, init?: RequestInit) => {27+ const request = new Request(input, init)28+ const body = await request.json() as { engine: string }29+ return Response.json({30+ svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 10"><text x="1" y="8">ok</text></svg>',31+ version: 'mermaid@11.17.0',32+ build: 'frame-test-1',33+ pipeline: [body.engine],34+ })35+ }),36+ },37+ }38+}39+40+function renderRequest(source: string): Request {41+ return new Request('https://mermaid.render.diagram.zip/v1/svg', {42+ method: 'POST',43+ headers: { 'Content-Type': 'application/json' },44+ body: JSON.stringify({ source }),45+ })46+}47+48+describe('browser-backed renderer unit', () => {49+ it('serves the renderer frame outside the public API namespace', async () => {50+ const response = await fetchUnit(51+ new Request('https://mermaid.render.diagram.zip/index.html') as Parameters<typeof fetchUnit>[0],52+ env() as unknown as Parameters<typeof fetchUnit>[1],53+ context,54+ )55+ expect(response.status).toBe(200)56+ expect(await response.text()).toContain('renderer frame')57+ })58+59+ it('renders through the private service while owning public identity headers', async () => {60+ const response = await fetchUnit(61+ renderRequest(`graph TD; A-->B-${crypto.randomUUID()}`) as Parameters<typeof fetchUnit>[0],62+ env() as unknown as Parameters<typeof fetchUnit>[1],63+ context,64+ )65+ expect(response.status).toBe(200)66+ expect(response.headers.get('X-Diagram-Renderer')).toBe('browser-run')67+ expect(response.headers.get('X-Diagram-Engine-Version')).toBe('mermaid@11.17.0')68+ expect(response.headers.get('X-Renderer-Build')).toBe('mermaid-test-browser-run-1')69+ expect(await response.text()).toContain('>ok</text>')70+ })71+72+ it('rejects body-selected engines without calling the private service', async () => {73+ let called = false74+ const response = await fetchUnit(75+ new Request('https://mermaid.render.diagram.zip/v1/svg', {76+ method: 'POST',77+ headers: { 'Content-Type': 'application/json' },78+ body: JSON.stringify({ engine: 'tikz', source: 'graph TD' }),79+ }) as Parameters<typeof fetchUnit>[0],80+ env(async () => { called = true; return Response.json({}) }) as unknown as Parameters<typeof fetchUnit>[1],81+ context,82+ )83+ expect(response.status).toBe(400)84+ expect(called).toBe(false)85+ })86+87+ it('normalizes private service failures to a stable gateway error', async () => {88+ const response = await fetchUnit(89+ renderRequest(`graph TD; failure-${crypto.randomUUID()}`) as Parameters<typeof fetchUnit>[0],90+ env(async () => Response.json({ error: { code: 'render_failed', message: 'sensitive detail' } }, { status: 500 })) as unknown as Parameters<typeof fetchUnit>[1],91+ context,92+ )93+ expect(response.status).toBe(502)94+ await expect(response.json()).resolves.toEqual({ error: { code: 'render_failed', message: 'The browser renderer could not render the diagram.' } })95+ })96+})
M renderers/scripts/smoke-units.mjs +4 -17
@@ -66,25 +66,9 @@ const structurizrEngines = new Set(['structurizr'])
6666 const d2Engines = new Set(['d2'])6767 const symbolatorEngines = new Set(['symbolator'])6868 const umletEngines = new Set(['umlet'])69-const clientEngines = new Set(['mermaid', 'bpmn', 'excalidraw', 'diagramsnet', 'tikz'])70-71-async function smokeClientUnit(engine) {72- const base = `https://${engine}.render.diagram.zip`73- const [capabilitiesResponse, frameResponse] = await Promise.all([74- fetch(`${base}/v1/capabilities`),75- fetch(`${base}/index.html`),76- ])77- if (!capabilitiesResponse.ok) throw new Error(`${engine}: capabilities HTTP ${capabilitiesResponse.status}`)78- if (!frameResponse.ok) throw new Error(`${engine}: frame HTTP ${frameResponse.status}`)79- const capabilities = await capabilitiesResponse.json()80- if (capabilities.id !== engine || capabilities.runtime !== 'client' || capabilities.format !== 'svg') {81- throw new Error(`${engine}: unexpected client capabilities ${JSON.stringify(capabilities)}`)82- }83- return `${engine.padEnd(10)} client ${capabilities.build}`84-}69+const browserRunEngines = new Set(['mermaid', 'bpmn', 'excalidraw', 'diagramsnet', 'tikz'])8570 8671 async function smoke([engine, filename]) {87- if (clientEngines.has(engine)) return smokeClientUnit(engine)8872 const source = await readFile(resolve(diagramDirectory, filename), 'utf8')8973 const endpoint = `https://${engine}.render.diagram.zip/v1/svg`9074 const response = await fetch(endpoint, {
@@ -124,6 +108,9 @@ async function smoke([engine, filename]) {
124108 if (pipeline !== expectedPipeline) {125109 throw new Error(`${engine}: unexpected pipeline ${pipeline}`)126110 }111+ if (browserRunEngines.has(engine) && response.headers.get('X-Diagram-Renderer') !== 'browser-run') {112+ throw new Error(`${engine}: expected browser-run renderer, received ${response.headers.get('X-Diagram-Renderer')}`)113+ }127114 if (pythonEngines.has(engine)) {128115 if (response.headers.get('X-Diagram-Renderer') !== 'edge-python') {129116 throw new Error(`${engine}: expected edge-python renderer, received ${response.headers.get('X-Diagram-Renderer')}`)
A renderers/shared/browser-unit.ts +219 -0
@@ -0,0 +1,219 @@
1+import { canonicalizeSvg, SvgNormalizationError, normalizationFor } from '../../shared/svg/index.js'2+3+const CACHE_MAX_AGE = 1_8004+const CACHE_SCHEMA = '1'5+const MAX_REQUEST_BYTES = 1_048_5766+const MAX_SOURCE_LENGTH = 524_2887+const MAX_OPTION_COUNT = 648+const 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(', ')13+14+export 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+}26+27+interface 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+}35+36+interface BrowserRendererBinding {37+ fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>38+}39+40+interface BrowserUnitEnv {41+ ASSETS: Fetcher42+ BROWSER_RENDERER: BrowserRendererBinding43+}44+45+class UnitError extends Error {46+ constructor(readonly status: number, readonly code: string, message: string) {47+ super(message)48+ this.name = 'UnitError'49+ }50+}51+52+function 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+}61+62+function 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+}67+68+function 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+}72+73+function 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+}78+79+async 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+}101+102+function 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+}115+116+function 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+}133+134+async 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+}143+144+function 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+}148+149+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}/${descriptor.id}/${descriptor.build}/${encoded}`)155+}156+157+function 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+}168+169+async 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+}189+190+export 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+}
M renderers/shared/engines.ts +1 -1