clone

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

refactor(diagram.zip): group shared renderer dependencies

Dami committed ee4e6f4
commit
ee4e6f4e5f050ee544d210789505bf4d27412732
parent
2a07eb762d8fd007258ac38511755dbbb361baf7
author
Dami <dami.etoile@gmail.com>
22 files changed, +206 -66
Mdiagramzip-render/README.md26+++++++++------
Mdiagramzip-render/scripts/deploy-compatibility-units.mjs20++++++------
Mdiagramzip-render/scripts/smoke-units.mjs23++++++++++++--
Mdiagramzip-render/src/unit.ts43++++++++++++++++++++-----
Adiagramzip-render/src/units/compatibility-group.ts15+++++++++
Adiagramzip-render/src/units/vega-family.ts5+++
Mdiagramzip-render/src/units/vega.ts6++-
Mdiagramzip-render/src/units/vegalite.ts6++-
Mdiagramzip-render/test/unit.test.ts51+++++++++++++++++++++++++++++-
Ddiagramzip-render/wrangler.compatibility/actdiag.jsonc1-
Adiagramzip-render/wrangler.compatibility/blockdiag-family.jsonc20++++++++++++
Ddiagramzip-render/wrangler.compatibility/blockdiag.jsonc1-
Ddiagramzip-render/wrangler.compatibility/c4plantuml.jsonc1-
Ddiagramzip-render/wrangler.compatibility/nwdiag.jsonc1-
Ddiagramzip-render/wrangler.compatibility/packetdiag.jsonc1-
Adiagramzip-render/wrangler.compatibility/plantuml-family.jsonc16+++++++++
Ddiagramzip-render/wrangler.compatibility/plantuml.jsonc1-
Ddiagramzip-render/wrangler.compatibility/rackdiag.jsonc1-
Ddiagramzip-render/wrangler.compatibility/seqdiag.jsonc1-
Adiagramzip-render/wrangler.units/vega-family.jsonc13++++++++
Ddiagramzip-render/wrangler.units/vega.jsonc10------
Ddiagramzip-render/wrangler.units/vegalite.jsonc10------
M diagramzip-render/README.md +16 -10
@@ -1,9 +1,10 @@
11 # diagramzip-render22 33 Cloudflare Worker rendering plane for diagram.zip. Every catalog engine owns4-`https://{engine}.render.diagram.zip`. The editor talks to that unit and never5-sends an engine selector to it. The older `/render/v1/svg` gateway remains only6-as a migration fallback.4+`https://{engine}.render.diagram.zip`. The hostname selects the engine; the5+editor never sends an engine selector. Engines that ship as one dependency or6+runtime share a Worker deployment while retaining distinct hostnames. The older7+`/render/v1/svg` gateway remains only as a migration fallback.78 89 The catalog deliberately contains all 30 diagram types. An engine does not910 disappear while its replacement is incomplete: its small compatibility unit
@@ -28,7 +29,7 @@ checks only structural SVG coverage. It does not perform pixel comparisons.
2829 2930 Server-rendered units expose `GET /v1/health`, `GET /v1/capabilities`, and3031 `POST /v1/svg`. The POST body contains source, options, metadata, and31-presentation, but never an engine field. Responses identify the exact unit,32+presentation, but never an engine field. Responses identify the dependency unit,3233 build, and pipeline with `X-Diagram-Unit`, `X-Renderer-Build`, and3334 `X-Diagram-Pipeline`.3435
@@ -37,18 +38,23 @@ sandboxed frame. The frame accepts only its own engine over the versioned
3738 postMessage protocol. Mermaid, BPMN, and Excalidraw have separate packages,3839 bundles, CSPs, deployments, and subdomains.3940 40-A unit may translate into another renderer. Translation is private to that41-unit and is declared as an ordered pipeline. Vega-Lite therefore owns42-Vega-Lite input and reports `vegalite,vega`; callers still address only the43-Vega-Lite unit.41+A unit may expose multiple catalog hostnames when those engines share the same42+dependency stack. BlockDiag, SeqDiag, ActDiag, NwDiag, PacketDiag, and RackDiag43+share `blockdiag-family`; PlantUML and C4 PlantUML share `plantuml-family`;44+Vega and Vega-Lite share `vega-family`. Requests are still selected only by a45+dedicated hostname, so a caller cannot choose an unrelated engine in the body.46+47+A unit may translate into another renderer. Translation is declared as an48+ordered pipeline whose first stage is the deployable unit. Vega-Lite therefore49+reports `vega-family,vega`; `X-Diagram-Engine` still reports `vegalite`.4450 4551 ## Current runtime split4652 4753 | Runtime | Engines |4854 | --- | --- |49-| Worker JavaScript | Bytefield, Nomnoml, Vega, Vega-Lite → Vega, WaveDrom |55+| Worker JavaScript | Bytefield, Nomnoml, Vega family (Vega and Vega-Lite → Vega), WaveDrom |5056 | Sandboxed browser unit | Mermaid, BPMN, Excalidraw |51-| One-engine compatibility unit | The remaining 22 engines |57+| Compatibility unit | 16 dependency units covering the remaining 22 engines |5258 5359 Compatibility units are the replacement seam, not the final runtime. DBML5460 currently proxies because its published package mixes module formats. GraphViz
M diagramzip-render/scripts/deploy-compatibility-units.mjs +10 -10
@@ -1,31 +1,31 @@
11 import { spawn } from 'node:child_process'22 import { resolve } from 'node:path'33 4-const engines = [5- 'plantuml', 'graphviz', 'd2', 'c4plantuml', 'blockdiag', 'seqdiag', 'actdiag', 'nwdiag',6- 'packetdiag', 'rackdiag', 'dbml', 'diagramsnet', 'ditaa', 'erd', 'goat', 'pikchr',4+const units = [5+ 'plantuml-family', 'graphviz', 'd2', 'blockdiag-family',6+ 'dbml', 'diagramsnet', 'ditaa', 'erd', 'goat', 'pikchr',77 'structurizr', 'svgbob', 'symbolator', 'tikz', 'umlet', 'wireviz',88 ]99 const wrangler = resolve(import.meta.dirname, '../node_modules/.bin/wrangler')10-const pending = [...engines]10+const pending = [...units]1111 const failures = []1212 13-async function deploy(engine) {13+async function deploy(unit) {1414 await new Promise((resolvePromise, reject) => {15- const child = spawn(wrangler, ['deploy', '--config', `wrangler.compatibility/${engine}.jsonc`], {15+ const child = spawn(wrangler, ['deploy', '--config', `wrangler.compatibility/${unit}.jsonc`], {1616 cwd: resolve(import.meta.dirname, '..'),1717 stdio: 'inherit',1818 })1919 child.on('error', reject)20- child.on('exit', code => code === 0 ? resolvePromise() : reject(new Error(`${engine} deployment exited ${code}`)))20+ child.on('exit', code => code === 0 ? resolvePromise() : reject(new Error(`${unit} deployment exited ${code}`)))2121 })2222 }2323 2424 async function worker() {2525 while (pending.length > 0) {26- const engine = pending.shift()26+ const unit = pending.shift()2727 try {28- await deploy(engine)28+ await deploy(unit)2929 } catch (error) {3030 failures.push(error instanceof Error ? error.message : String(error))3131 }
@@ -37,5 +37,5 @@ if (failures.length > 0) {
3737 for (const failure of failures) console.error(failure)3838 process.exitCode = 13939 } else {40- console.log(`${engines.length}/${engines.length} compatibility units deployed`)40+ console.log(`${units.length}/${units.length} compatibility units deployed`)4141 }
M diagramzip-render/scripts/smoke-units.mjs +20 -3
@@ -32,6 +32,19 @@ const fixtures = {
3232 wireviz: 'wireviz.yaml',3333 }3434 35+const sharedUnits = {36+ plantuml: 'plantuml-family',37+ c4plantuml: 'plantuml-family',38+ blockdiag: 'blockdiag-family',39+ seqdiag: 'blockdiag-family',40+ actdiag: 'blockdiag-family',41+ nwdiag: 'blockdiag-family',42+ packetdiag: 'blockdiag-family',43+ rackdiag: 'blockdiag-family',44+ vega: 'vega-family',45+ vegalite: 'vega-family',46+}47+3548 async function smoke([engine, filename]) {3649 const source = await readFile(resolve(fixtureDirectory, filename), 'utf8')3750 const endpoint = `https://${engine}.render.diagram.zip/v1/svg`
@@ -49,9 +62,13 @@ async function smoke([engine, filename]) {
4962 const body = await response.text()5063 if (!response.ok) throw new Error(`${engine}: HTTP ${response.status}: ${body.slice(0, 240)}`)5164 if (!/<svg[\s>]/i.test(body)) throw new Error(`${engine}: response is not SVG`)52- if (response.headers.get('X-Diagram-Unit') !== engine) throw new Error(`${engine}: unit identity header is missing`)65+ const expectedUnit = sharedUnits[engine] ?? engine66+ if (response.headers.get('X-Diagram-Unit') !== expectedUnit) {67+ throw new Error(`${engine}: expected unit ${expectedUnit}, received ${response.headers.get('X-Diagram-Unit')}`)68+ }5369 const pipeline = response.headers.get('X-Diagram-Pipeline')54- if (engine === 'vegalite' ? pipeline !== 'vegalite,vega' : pipeline !== engine) {70+ const expectedPipeline = engine === 'vegalite' ? `${expectedUnit},vega` : expectedUnit71+ if (pipeline !== expectedPipeline) {5572 throw new Error(`${engine}: unexpected pipeline ${pipeline}`)5673 }5774 return `${engine.padEnd(10)} ${response.headers.get('X-Diagram-Cache')?.padEnd(4)} ${body.length} bytes`
@@ -63,4 +80,4 @@ for (let index = 0; index < entries.length; index += 4) {
6380 results.push(...await Promise.all(entries.slice(index, index + 4).map(smoke)))6481 }6582 for (const result of results) console.log(result)66-console.log(`${results.length}/${results.length} independent renderer units returned SVG`)83+console.log(`${results.length}/${results.length} catalog engines returned SVG through dependency-backed units`)
M diagramzip-render/src/unit.ts +34 -9
@@ -55,6 +55,7 @@ function responseWithCacheStatus(response: Response, status: 'HIT' | 'MISS'): Re
5555 }5656 5757 async function render(58+ unitId: string,5859 descriptor: RendererUnitDescriptor,5960 request: RenderRequest,6061 build: string,
@@ -73,32 +74,51 @@ async function render(
7374 'X-Diagram-Engine': descriptor.id,7475 'X-Diagram-Engine-Version': rendered.engineVersion,7576 'X-Diagram-Renderer': rendered.runtime,76- 'X-Diagram-Unit': descriptor.id,77- 'X-Diagram-Pipeline': [descriptor.id, ...(descriptor.pipeline ?? [])].join(','),77+ 'X-Diagram-Unit': unitId,78+ 'X-Diagram-Pipeline': [unitId, ...(descriptor.pipeline ?? [])].join(','),7879 'X-Renderer-Build': build,7980 },8081 })8182 }8283 83-function capabilities(descriptor: RendererUnitDescriptor, build: string): Response {84+function capabilities(unitId: string, descriptor: RendererUnitDescriptor, build: string): Response {8485 return json({86+ unit: unitId,8587 id: descriptor.id,8688 kind: descriptor.kind,8789 format: 'svg',8890 runtime: descriptor.adapter.runtime,8991 version: descriptor.adapter.version,9092 build,91- pipeline: [descriptor.id, ...(descriptor.pipeline ?? [])],93+ pipeline: [unitId, ...(descriptor.pipeline ?? [])],9294 knownLosses: descriptor.knownLosses ?? [],9395 }, { headers: { 'Cache-Control': 'public, max-age=300' } })9496 }9597 9698 export function createRendererUnit(descriptor: RendererUnitDescriptor): ExportedHandler<RendererUnitEnv> {97- if (descriptor.adapter.id !== descriptor.id) throw new Error('Renderer unit adapter identity does not match its unit identity.')99+ return createRendererUnitGroup(descriptor.id, [descriptor])100+}101+102+function engineFromHostname(hostname: string): string {103+ return hostname.split('.')[0]?.toLowerCase() ?? ''104+}105+106+export function createRendererUnitGroup(107+ unitId: string,108+ descriptors: readonly RendererUnitDescriptor[],109+): ExportedHandler<RendererUnitEnv> {110+ if (descriptors.length === 0) throw new Error('A renderer unit group must contain at least one engine.')111+ const byEngine = new Map<EngineId, RendererUnitDescriptor>()112+ for (const descriptor of descriptors) {113+ if (descriptor.adapter.id !== descriptor.id) throw new Error('Renderer adapter identity does not match its engine identity.')114+ if (byEngine.has(descriptor.id)) throw new Error(`Renderer engine ${descriptor.id} is declared more than once.`)115+ byEngine.set(descriptor.id, descriptor)116+ }98117 99118 return {100119 async fetch(request, env, ctx): Promise<Response> {101120 const url = new URL(request.url)121+ const requestedEngine = engineFromHostname(url.hostname)102122 if (request.method === 'OPTIONS') {103123 return new Response(null, {104124 status: 204,
@@ -111,8 +131,12 @@ export function createRendererUnit(descriptor: RendererUnitDescriptor): Exported
111131 })112132 }113133 try {114- if (request.method === 'GET' && url.pathname === '/v1/health') return json({ ok: true, id: descriptor.id })115- if (request.method === 'GET' && url.pathname === '/v1/capabilities') return capabilities(descriptor, env.RENDERER_BUILD)134+ const descriptor = byEngine.get(requestedEngine as EngineId)135+ if (descriptor === undefined) {136+ return json({ error: { code: 'not_found', message: 'This renderer unit does not serve the requested engine.' } }, { status: 404 })137+ }138+ if (request.method === 'GET' && url.pathname === '/v1/health') return json({ ok: true, unit: unitId, id: descriptor.id })139+ if (request.method === 'GET' && url.pathname === '/v1/capabilities') return capabilities(unitId, descriptor, env.RENDERER_BUILD)116140 if (request.method !== 'POST' || url.pathname !== '/v1/svg') {117141 return json({ error: { code: 'not_found', message: 'Route not found.' } }, { status: 404 })118142 }
@@ -120,14 +144,15 @@ export function createRendererUnit(descriptor: RendererUnitDescriptor): Exported
120144 const cacheKey = await cacheRequestFor(input, env.RENDERER_BUILD)121145 const cached = await caches.default.match(cacheKey)122146 if (cached !== undefined) return responseWithCacheStatus(cached, 'HIT')123- const response = await render(descriptor, input, env.RENDERER_BUILD, request.signal)147+ const response = await render(unitId, descriptor, input, env.RENDERER_BUILD, request.signal)124148 ctx.waitUntil(caches.default.put(cacheKey, response.clone()))125149 return responseWithCacheStatus(response, 'MISS')126150 } catch (error) {127151 if (!(error instanceof RequestError || error instanceof RenderError)) {128152 console.error(JSON.stringify({129153 message: 'unhandled renderer unit error',130- unit: descriptor.id,154+ unit: unitId,155+ engine: requestedEngine,131156 error: error instanceof Error ? error.message : String(error),132157 path: url.pathname,133158 }))
A diagramzip-render/src/units/compatibility-group.ts +15 -0
A diagramzip-render/src/units/vega-family.ts +5 -0
M diagramzip-render/src/units/vega.ts +4 -2
M diagramzip-render/src/units/vegalite.ts +4 -2
M diagramzip-render/test/unit.test.ts +49 -2
@@ -1,6 +1,6 @@
11 import { describe, expect, it } from 'vitest'2-import { createRendererUnit } from '../src/unit'3-import type { RendererAdapter } from '../src/types'2+import { createRendererUnit, createRendererUnitGroup } from '../src/unit'3+import type { EngineId, RendererAdapter } from '../src/types'44 55 const adapter: RendererAdapter = {66 id: 'vegalite',
@@ -61,3 +61,50 @@ describe('renderer unit protocol', () => {
6161 })6262 })6363 })64+65+function testAdapter(id: EngineId): RendererAdapter {66+ return {67+ id,68+ runtime: 'origin',69+ version: 'test@1',70+ async render() {71+ return {72+ body: `<svg xmlns="http://www.w3.org/2000/svg"><text>${id}</text></svg>`,73+ contentType: 'image/svg+xml',74+ engineVersion: 'test@1',75+ runtime: 'origin',76+ }77+ },78+ }79+}80+81+describe('renderer dependency groups', () => {82+ const group = createRendererUnitGroup('blockdiag-family', [83+ { id: 'blockdiag', kind: 'compatibility', adapter: testAdapter('blockdiag') },84+ { id: 'seqdiag', kind: 'compatibility', adapter: testAdapter('seqdiag') },85+ ])86+ const fetchGroup = group.fetch!87+88+ it('selects an engine from its dedicated hostname while reporting the shared unit', async () => {89+ const response = await fetchGroup(new Request('https://seqdiag.render.diagram.zip/v1/svg', {90+ method: 'POST',91+ headers: { 'Content-Type': 'application/json' },92+ body: JSON.stringify({ source: 'A -> B' }),93+ }) as Parameters<typeof fetchGroup>[0], env, context)94+95+ expect(response.status).toBe(200)96+ expect(response.headers.get('X-Diagram-Engine')).toBe('seqdiag')97+ expect(response.headers.get('X-Diagram-Unit')).toBe('blockdiag-family')98+ expect(response.headers.get('X-Diagram-Pipeline')).toBe('blockdiag-family')99+ })100+101+ it('does not serve an engine outside the dependency group', async () => {102+ const response = await fetchGroup(103+ new Request('https://graphviz.render.diagram.zip/v1/capabilities') as Parameters<typeof fetchGroup>[0],104+ env,105+ context,106+ )107+108+ expect(response.status).toBe(404)109+ })110+})
D diagramzip-render/wrangler.compatibility/actdiag.jsonc +0 -1
A diagramzip-render/wrangler.compatibility/blockdiag-family.jsonc +20 -0
D diagramzip-render/wrangler.compatibility/blockdiag.jsonc +0 -1
D diagramzip-render/wrangler.compatibility/c4plantuml.jsonc +0 -1
D diagramzip-render/wrangler.compatibility/nwdiag.jsonc +0 -1
D diagramzip-render/wrangler.compatibility/packetdiag.jsonc +0 -1
A diagramzip-render/wrangler.compatibility/plantuml-family.jsonc +16 -0
D diagramzip-render/wrangler.compatibility/plantuml.jsonc +0 -1
D diagramzip-render/wrangler.compatibility/rackdiag.jsonc +0 -1
D diagramzip-render/wrangler.compatibility/seqdiag.jsonc +0 -1
A diagramzip-render/wrangler.units/vega-family.jsonc +13 -0
D diagramzip-render/wrangler.units/vega.jsonc +0 -10
D diagramzip-render/wrangler.units/vegalite.jsonc +0 -10