clone

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

refactor: route editor and docs through HTTP renderers

Dami committed 820567b
Remove the five in-browser renderer adapters from application code and send every preview and documentation example through its public renderer hostname. Delete the legacy iframe coordinator while preserving canonical SVG materialization and add routing coverage for all 30 engines.
commit
820567b5b7b420c083fc3c256988b49b666a1dbe
parent
02991b85b1609dda412c9ce5abfd808ac56db1ba
author
Dami <dami.etoile@gmail.com>
13 files changed, +49 -514
Mapps/docs/scripts/check-rendered-examples.mjs47+----------
Mapps/docs/src/components/DiagramExample/index.js141++++--------------------------
Mapps/docs/src/components/DiagramExample/rendererRouting.mjs14---
Mapps/docs/test/diagram-example.test.mjs12+-
Mapps/editor/PERSISTENCE.md3+-
Dapps/editor/src/client-renderers.js139------------------------------
Mapps/editor/src/launcher-previews.js8--
Mapps/editor/src/preview.js20+----
Mapps/editor/src/renderer-units.js5+
Dapps/editor/test/client-renderers.test.js70---------------
Mapps/editor/test/launcher-previews.test.js24+----
Mapps/editor/test/preview-routing.test.js59+------------
Mapps/editor/test/renderer-units.test.js21++---
M apps/docs/scripts/check-rendered-examples.mjs +1 -46
@@ -2,59 +2,14 @@ import { readFile, readdir } from 'node:fs/promises'
22 import { dirname, join } from 'node:path'33 import { fileURLToPath } from 'node:url'44 import { canonicalizeSvg, materializeSvg, supportedAppearances } from '../../../shared/svg/index.js'5-import { CLIENT_RENDERERS, httpRendererUrlFor } from '../src/components/DiagramExample/rendererRouting.mjs'5+import { httpRendererUrlFor } from '../src/components/DiagramExample/rendererRouting.mjs'66 77 const site = join(dirname(fileURLToPath(import.meta.url)), '..')88 const examplesDir = join(site, 'static', 'examples')99 const names = (await readdir(examplesDir)).filter((name) => name.endsWith('.json')).sort()1010 11-function cspSource(document, headers) {12- return headers.get('Content-Security-Policy')13- ?? document.match(/http-equiv="Content-Security-Policy"\s+content="([^"]+)"/i)?.[1]14- ?? ''15-}16-17-function cspAllows(csp, directive, origin) {18- const values = csp.split(';')19- .map(value => value.trim().split(/\s+/))20- .find(([name]) => name === directive)21- ?.slice(1) ?? []22- return values.includes('*') || values.includes(origin)23-}24-2511 async function check(name) {2612 const example = JSON.parse(await readFile(join(examplesDir, name), 'utf8'))27- if (CLIENT_RENDERERS[example.engine]) {28- const frameUrl = CLIENT_RENDERERS[example.engine]29- const response = await fetch(frameUrl)30- const body = await response.text()31- const scriptUrls = [...body.matchAll(/<script[^>]+src="([^"]+)"/gi)]32- .map((match) => new URL(match[1], frameUrl).href)33- const scripts = await Promise.all(scriptUrls.map(async (scriptUrl) => {34- const scriptResponse = await fetch(scriptUrl)35- return {36- ok: scriptResponse.ok,37- body: await scriptResponse.text(),38- }39- }))40- const hasProtocol = scripts.some((script) => script.ok && script.body.includes('diagram.zip:renderer:v1'))41- if (!response.ok || !hasProtocol) {42- throw new Error(`${example.engine}: client frame unavailable (HTTP ${response.status})`)43- }44- if (example.engine === 'excalidraw' && scripts.some((script) => script.body.includes('https://esm.sh'))) {45- const fallbackOrigin = 'https://esm.sh'46- if (!cspAllows(cspSource(body, response.headers), 'font-src', fallbackOrigin)) {47- throw new Error(`${example.engine}: frame CSP font-src blocks ${fallbackOrigin}`)48- }49- const fallbackFont = await fetch(`${fallbackOrigin}/@excalidraw/excalidraw@0.18.1/dist/prod/fonts/Cascadia/CascadiaCode-Regular.woff2`)50- const fallbackAvailable = fallbackFont.ok && fallbackFont.headers.get('Access-Control-Allow-Origin') === '*'51- await fallbackFont.body?.cancel()52- if (!fallbackAvailable) {53- throw new Error(`${example.engine}: packaged font fallback is unavailable (HTTP ${fallbackFont.status})`)54- }55- }56- return `${example.engine} client frame`57- }5813 const response = await fetch(httpRendererUrlFor(example.engine), {5914 method: 'POST',6015 headers: { Accept: 'image/svg+xml', 'Content-Type': 'application/json' },
M apps/docs/src/components/DiagramExample/index.js +17 -124
@@ -1,112 +1,10 @@
11 import React, {useCallback, useEffect, useRef, useState} from 'react';2-import {canonicalizeSvg, materializeSvg, supportedAppearances} from '../../../../../shared/svg/index.js';2+import {materializeSvg, supportedAppearances} from '../../../../../shared/svg/index.js';33 import {canonicalizeHttpRendererSvg} from './httpSvg.mjs';44 import {fitView, zoomView} from './viewMath.mjs';5-import {clientFrameUrlFor, httpRendererUrlFor} from './rendererRouting.mjs';5+import {httpRendererUrlFor} from './rendererRouting.mjs';66 import styles from './styles.module.css';77 8-const CHANNEL = 'diagram.zip:renderer:v1';9-const RENDER_TIMEOUT = 60_000;10-const clientFrames = new Map();11-12-class RendererFrame {13- constructor(frameUrl) {14- this.frameUrl = frameUrl;15- this.frame = null;16- this.ready = null;17- this.pending = new Map();18- this.sequence = 0;19- this.handleMessage = (event) => this.receive(event);20- window.addEventListener('message', this.handleMessage);21- }22-23- ensureFrame() {24- if (this.frame) return this.ready;25- const frame = document.createElement('iframe');26- frame.title = 'Diagram renderer';27- frame.setAttribute('sandbox', 'allow-scripts');28- frame.setAttribute('aria-hidden', 'true');29- frame.tabIndex = -1;30- Object.assign(frame.style, {31- position: 'fixed', left: '-200vw', top: '0', width: '1280px', height: '800px',32- border: '0', pointerEvents: 'none', visibility: 'hidden',33- });34- this.ready = new Promise((resolve, reject) => {35- this.resolveReady = resolve;36- this.rejectReady = reject;37- });38- frame.addEventListener('error', () => this.rejectReady?.(new Error('The client renderer could not be loaded.')), {once: true});39- frame.src = this.frameUrl;40- this.frame = frame;41- document.body.append(frame);42- return this.ready;43- }44-45- receive(event) {46- if (!this.frame || event.source !== this.frame.contentWindow || event.data?.channel !== CHANNEL) return;47- if (event.data.type === 'ready') {48- this.resolveReady?.();49- this.resolveReady = null;50- this.rejectReady = null;51- return;52- }53- if (event.data.type !== 'result') return;54- const pending = this.pending.get(event.data.requestId);55- if (!pending) return;56- this.pending.delete(event.data.requestId);57- pending.finish();58- if (event.data.ok && typeof event.data.svg === 'string') pending.resolve({59- svg: event.data.svg,60- version: typeof event.data.version === 'string' ? event.data.version : '',61- });62- else pending.reject(new Error(event.data.error || 'Client rendering failed.'));63- }64-65- async render(engine, source, signal) {66- let loadTimeout;67- try {68- await Promise.race([69- this.ensureFrame(),70- new Promise((_, reject) => { loadTimeout = setTimeout(() => reject(new Error('The client renderer timed out while loading.')), RENDER_TIMEOUT); }),71- ]);72- } finally {73- clearTimeout(loadTimeout);74- }75- if (signal.aborted) throw signal.reason || new DOMException('Render superseded.', 'AbortError');76- const requestId = `${Date.now().toString(36)}-${++this.sequence}`;77- return new Promise((resolve, reject) => {78- const timeout = setTimeout(() => {79- this.pending.delete(requestId);80- signal.removeEventListener('abort', onAbort);81- reject(new Error('The client renderer timed out.'));82- }, RENDER_TIMEOUT);83- const onAbort = () => {84- this.pending.delete(requestId);85- clearTimeout(timeout);86- reject(signal.reason || new DOMException('Render superseded.', 'AbortError'));87- };88- const finish = () => {89- clearTimeout(timeout);90- signal.removeEventListener('abort', onAbort);91- };92- signal.addEventListener('abort', onAbort, {once: true});93- this.pending.set(requestId, {resolve, reject, finish});94- this.frame.contentWindow.postMessage({channel: CHANNEL, type: 'render', requestId, engine, source}, '*');95- });96- }97-}98-99-function clientRendererFor(engine) {100- const frameUrl = clientFrameUrlFor(engine);101- if (!frameUrl) return null;102- let renderer = clientFrames.get(frameUrl);103- if (!renderer) {104- renderer = new RendererFrame(frameUrl);105- clientFrames.set(frameUrl, renderer);106- }107- return renderer;108-}109-1108 function normalizeSvg(source) {1119 const document = new DOMParser().parseFromString(source, 'image/svg+xml');11210 const svg = document.documentElement;
@@ -172,27 +70,22 @@ export default function DiagramExample({engine, label, sourceUrl}) {
17270 const exampleResponse = await fetch(sourceUrl, {signal: abortController.signal});17371 if (!exampleResponse.ok) throw new Error('The example source is not available.');17472 const example = await exampleResponse.json();175- const clientRenderer = clientRendererFor(engine);17673 let source;177- if (clientRenderer) {178- const clientResult = await clientRenderer.render(engine, example.source, abortController.signal);179- source = canonicalizeSvg(clientResult.svg, {title: '', description: ''}, engine, clientResult.version);180- } else {181- const renderResponse = await fetch(httpRendererUrlFor(engine), {182- method: 'POST',183- headers: {Accept: 'image/svg+xml', 'Content-Type': 'application/json'},184- body: JSON.stringify({185- source: example.source,186- format: 'svg',187- options: {},188- metadata: {},189- presentation: {background: '', padding: 0, frame: false},190- }),191- signal: abortController.signal,192- });193- if (!renderResponse.ok) throw new Error(await responseError(renderResponse));194- source = canonicalizeHttpRendererSvg(await renderResponse.text(), engine, renderResponse.headers);195- }74+ const renderResponse = await fetch(httpRendererUrlFor(engine), {75+ method: 'POST',76+ headers: {Accept: 'image/svg+xml', 'Content-Type': 'application/json'},77+ body: JSON.stringify({78+ source: example.source,79+ format: 'svg',80+ options: {},81+ metadata: {},82+ presentation: {background: '', padding: 0, frame: false},83+ }),84+ signal: abortController.signal,85+ });86+ if (!renderResponse.ok) throw new Error(await responseError(renderResponse));87+ source = canonicalizeHttpRendererSvg(await renderResponse.text(), engine, renderResponse.headers);88+19689 const appearances = supportedAppearances(source);19790 const appearance = appearances.includes('auto-transparent')19891 ? 'auto-transparent'
M apps/docs/src/components/DiagramExample/rendererRouting.mjs +0 -14
M apps/docs/test/diagram-example.test.mjs +6 -6
@@ -1,17 +1,17 @@
11 import assert from 'node:assert/strict';22 import test from 'node:test';33 import {fitView, zoomView} from '../src/components/DiagramExample/viewMath.mjs';4-import {clientFrameUrlFor, httpRendererUrlFor} from '../src/components/DiagramExample/rendererRouting.mjs';4+import {httpRendererUrlFor} from '../src/components/DiagramExample/rendererRouting.mjs';55 import {canonicalizeHttpRendererSvg, rendererVersionFromHeaders} from '../src/components/DiagramExample/httpSvg.mjs';66 import {materializeSvg} from '../../../shared/svg/index.js';77 8-test('routes client examples to their isolated renderer frame', () => {9- assert.equal(clientFrameUrlFor('tikz'), 'https://tikz.render.diagram.zip/index.html?v=1');10- assert.equal(clientFrameUrlFor('graphviz'), null);11-});12-138 test('routes HTTP examples to their dedicated renderer unit', () => {149 assert.equal(httpRendererUrlFor('graphviz'), 'https://graphviz.render.diagram.zip/v1/svg');10+ assert.equal(httpRendererUrlFor('mermaid'), 'https://mermaid.render.diagram.zip/v1/svg');11+ assert.equal(httpRendererUrlFor('bpmn'), 'https://bpmn.render.diagram.zip/v1/svg');12+ assert.equal(httpRendererUrlFor('excalidraw'), 'https://excalidraw.render.diagram.zip/v1/svg');13+ assert.equal(httpRendererUrlFor('diagramsnet'), 'https://diagramsnet.render.diagram.zip/v1/svg');14+ assert.equal(httpRendererUrlFor('tikz'), 'https://tikz.render.diagram.zip/v1/svg');1515 });1616 1717 test('canonicalizes raw HTTP renderer SVG before docs appearance materialization', () => {
M apps/editor/PERSISTENCE.md +2 -1
D apps/editor/src/client-renderers.js +0 -139
@@ -1,139 +0,0 @@
1-const CHANNEL = 'diagram.zip:renderer:v1'2-const RENDER_TIMEOUT = 60_0003-4-export const CLIENT_RENDERERS = Object.freeze({5- mermaid: Object.freeze({ frameUrl: 'https://mermaid.render.diagram.zip/index.html?v=1' }),6- bpmn: Object.freeze({ frameUrl: 'https://bpmn.render.diagram.zip/index.html?v=1' }),7- excalidraw: Object.freeze({ frameUrl: 'https://excalidraw.render.diagram.zip/index.html?v=4' }),8- diagramsnet: Object.freeze({ frameUrl: 'https://diagramsnet.render.diagram.zip/index.html?v=1' }),9- tikz: Object.freeze({ frameUrl: 'https://tikz.render.diagram.zip/index.html?v=2' }),10-})11-12-export const CLIENT_RENDERER_IDS = Object.freeze(Object.keys(CLIENT_RENDERERS))13-14-function abortError(reason) {15- if (reason?.name === 'AbortError') return reason16- return new DOMException('Render superseded.', 'AbortError')17-}18-19-export class RendererFrame {20- constructor({ documentObject = document, windowObject = window, frameUrl } = {}) {21- this.document = documentObject22- this.window = windowObject23- this.frameUrl = frameUrl24- this.frame = null25- this.ready = null26- this.resolveReady = null27- this.rejectReady = null28- this.sequence = 029- this.pending = new Map()30- this.handleMessage = event => this.receive(event)31- this.window.addEventListener('message', this.handleMessage)32- }33-34- ensureFrame() {35- if (this.frame) return this.ready36- const frame = this.document.createElement('iframe')37- frame.title = 'Diagram renderer'38- frame.setAttribute('sandbox', 'allow-scripts')39- frame.setAttribute('aria-hidden', 'true')40- frame.tabIndex = -141- Object.assign(frame.style, {42- position: 'fixed',43- left: '-200vw',44- top: '0',45- width: '1280px',46- height: '800px',47- border: '0',48- pointerEvents: 'none',49- visibility: 'hidden',50- })51- this.ready = new Promise((resolve, reject) => {52- this.resolveReady = resolve53- this.rejectReady = reject54- })55- frame.addEventListener('error', () => this.rejectReady?.(new Error('The client renderer could not be loaded.')), { once: true })56- frame.src = this.frameUrl57- this.frame = frame58- this.document.body.append(frame)59- return this.ready60- }61-62- receive(event) {63- if (!this.frame || event.source !== this.frame.contentWindow || event.data?.channel !== CHANNEL) return64- if (event.data.type === 'ready') {65- this.resolveReady?.()66- this.resolveReady = null67- this.rejectReady = null68- return69- }70- if (event.data.type !== 'result') return71- const pending = this.pending.get(event.data.requestId)72- if (!pending) return73- this.pending.delete(event.data.requestId)74- pending.finish()75- if (event.data.ok && typeof event.data.svg === 'string') {76- pending.resolve({77- body: event.data.svg,78- version: event.data.version,79- build: event.data.build,80- pipeline: event.data.pipeline,81- runtime: 'client',82- })83- } else {84- pending.reject(new Error(event.data.error || 'Client rendering failed.'))85- }86- }87-88- async render(engine, source, signal) {89- let loadTimeout90- try {91- await Promise.race([92- this.ensureFrame(),93- new Promise((_, reject) => {94- loadTimeout = setTimeout(() => reject(new Error('The client renderer timed out while loading.')), RENDER_TIMEOUT)95- }),96- ])97- } finally {98- clearTimeout(loadTimeout)99- }100- if (signal.aborted) throw abortError(signal.reason)101- const requestId = `${Date.now().toString(36)}-${++this.sequence}`102- return new Promise((resolve, reject) => {103- const timeout = setTimeout(() => {104- this.pending.delete(requestId)105- signal.removeEventListener('abort', onAbort)106- reject(new Error('The client renderer timed out.'))107- }, RENDER_TIMEOUT)108- const onAbort = () => {109- this.pending.delete(requestId)110- clearTimeout(timeout)111- reject(abortError(signal.reason))112- }113- const finish = () => {114- clearTimeout(timeout)115- signal.removeEventListener('abort', onAbort)116- }117- signal.addEventListener('abort', onAbort, { once: true })118- this.pending.set(requestId, { resolve, reject, finish })119- this.frame.contentWindow.postMessage({ channel: CHANNEL, type: 'render', requestId, engine, source }, '*')120- })121- }122-}123-124-const frames = new Map()125-126-export function clientAdapterFor(engine) {127- const renderer = CLIENT_RENDERERS[engine]128- if (!renderer) return null129- let frame = frames.get(renderer.frameUrl)130- if (!frame) {131- frame = new RendererFrame({ frameUrl: renderer.frameUrl })132- frames.set(renderer.frameUrl, frame)133- }134- return {135- id: engine,136- runtime: 'client',137- render: ({ source }, signal) => frame.render(engine, source, signal),138- }139-}
M apps/editor/src/launcher-previews.js +0 -8
M apps/editor/src/preview.js +1 -19
M apps/editor/src/renderer-units.js +5 -0
D apps/editor/test/client-renderers.test.js +0 -70
@@ -1,70 +0,0 @@
1-import assert from 'node:assert/strict'2-import test from 'node:test'3-import { CLIENT_RENDERERS, CLIENT_RENDERER_IDS, RendererFrame, clientAdapterFor } from '../src/client-renderers.js'4-5-const CHANNEL = 'diagram.zip:renderer:v1'6-7-function fakeBrowser() {8- let listener9- const attributes = new Map()10- const contentWindow = {11- postMessage(message) {12- queueMicrotask(() => listener({13- source: contentWindow,14- data: {15- channel: CHANNEL,16- type: 'result',17- requestId: message.requestId,18- ok: true,19- svg: '<svg/>',20- version: 'mermaid@test',21- build: 'mermaid-test-unit-1',22- pipeline: ['mermaid'],23- },24- }))25- },26- }27- const frame = {28- style: {},29- contentWindow,30- setAttribute(name, value) { attributes.set(name, value) },31- addEventListener() {},32- }33- const windowObject = { addEventListener(_name, callback) { listener = callback } }34- const documentObject = {35- createElement() { return frame },36- body: { append() { queueMicrotask(() => listener({ source: contentWindow, data: { channel: CHANNEL, type: 'ready' } })) } },37- }38- return { attributes, documentObject, frame, windowObject }39-}40-41-test('registers the client-native renderers', () => {42- assert.deepEqual(CLIENT_RENDERER_IDS, ['mermaid', 'bpmn', 'excalidraw', 'diagramsnet', 'tikz'])43- assert.equal(CLIENT_RENDERERS.mermaid.frameUrl, 'https://mermaid.render.diagram.zip/index.html?v=1')44- assert.equal(CLIENT_RENDERERS.bpmn.frameUrl, 'https://bpmn.render.diagram.zip/index.html?v=1')45- assert.equal(CLIENT_RENDERERS.excalidraw.frameUrl, 'https://excalidraw.render.diagram.zip/index.html?v=4')46- assert.equal(CLIENT_RENDERERS.diagramsnet.frameUrl, 'https://diagramsnet.render.diagram.zip/index.html?v=1')47- assert.equal(CLIENT_RENDERERS.tikz.frameUrl, 'https://tikz.render.diagram.zip/index.html?v=2')48- assert.equal(clientAdapterFor('graphviz'), null)49-})50-51-test('renders through a script-only unique-origin frame', async () => {52- const browser = fakeBrowser()53- const renderer = new RendererFrame(browser)54- const result = await renderer.render('mermaid', 'flowchart LR\nA-->B', new AbortController().signal)55- assert.equal(result.body, '<svg/>')56- assert.equal(result.runtime, 'client')57- assert.equal(result.version, 'mermaid@test')58- assert.equal(result.build, 'mermaid-test-unit-1')59- assert.deepEqual(result.pipeline, ['mermaid'])60- assert.equal(browser.attributes.get('sandbox'), 'allow-scripts')61- assert.equal(browser.attributes.has('allow-same-origin'), false)62-})63-64-test('ignores renderer messages from any other window', async () => {65- const browser = fakeBrowser()66- const renderer = new RendererFrame(browser)67- const render = renderer.render('mermaid', 'flowchart LR\nA-->B', new AbortController().signal)68- renderer.receive({ source: {}, data: { channel: CHANNEL, type: 'result', requestId: 'forged', ok: true, svg: '<script/>' } })69- assert.equal((await render).body, '<svg/>')70-})
M apps/editor/test/launcher-previews.test.js +4 -20
@@ -8,7 +8,6 @@ test('renders an HTTP preview once and caches its object URL', async () => {
88 const requests = []99 const renderer = createLauncherPreviewRenderer({1010 endpointFor: type => `https://${type}.example/render`,11- clientAdapterForImpl: () => null,1211 fetchImpl: async (url, init) => {1312 requests.push({ url, init })1413 return { ok: true, text: async () => '<svg viewBox="0 0 1 1"></svg>' }
@@ -25,26 +24,10 @@ test('renders an HTTP preview once and caches its object URL', async () => {
2524 assert.equal(JSON.parse(requests[0].init.body).source, state.source)2625 })2726 28-test('uses a client renderer when one is registered', async () => {29- let calls = 030- const renderer = createLauncherPreviewRenderer({31- clientAdapterForImpl: type => type === 'mermaid' ? {32- render: async () => { calls++; return { body: '<svg></svg>' } },33- } : null,34- endpointFor: () => { throw new Error('HTTP renderer should not be used') },35- createObjectURL: () => 'blob:mermaid',36- transformSvg: rendered => rendered.body,37- })38-39- assert.equal(await renderer.render({ type: 'mermaid', source: 'flowchart LR' }), 'blob:mermaid')40- assert.equal(calls, 1)41-})42-4327 test('accepts renderer SVGs with an XML declaration', async () => {4428 const renderer = createLauncherPreviewRenderer({45- clientAdapterForImpl: () => ({46- render: async () => ({ body: '<?xml version="1.0" encoding="UTF-8"?><svg viewBox="0 0 1 1"></svg>' }),47- }),29+ endpointFor: () => 'https://symbolator.example/render',30+ fetchImpl: async () => ({ ok: true, text: async () => '<?xml version="1.0" encoding="UTF-8"?><svg viewBox="0 0 1 1"></svg>' }),4831 createObjectURL: () => 'blob:xml-svg',4932 transformSvg: rendered => rendered.body,5033 })
@@ -54,7 +37,8 @@ test('accepts renderer SVGs with an XML declaration', async () => {
5437 5538 test('rejects malformed renderer output and includes source in cache identity', async () => {5639 const renderer = createLauncherPreviewRenderer({57- clientAdapterForImpl: () => ({ render: async () => ({ body: '<html></html>' }) }),40+ endpointFor: () => 'https://bad.example/render',41+ fetchImpl: async () => ({ ok: true, text: async () => '<html></html>' }),5842 transformSvg: rendered => rendered.body,5943 })6044 await assert.rejects(renderer.render({ type: 'bad', source: 'bad' }), /did not return an SVG/)
M apps/editor/test/preview-routing.test.js +4 -55
@@ -6,6 +6,7 @@ import {
66 previewThemeForAppearance,77 previewViewKey,88 } from '../src/preview.js'9+import { httpRendererUnitFor } from '../src/renderer-units.js'910 1011 const request = {1112 source: 'blockdiag { A -> B; }',
@@ -66,62 +67,10 @@ test('surfaces a GraphViz-family renderer failure', async () => {
6667 assert.deepEqual(endpoints, ['https://erd.render.diagram.zip/v1/svg'])6768 })6869 69-test('does not send a failed client renderer to an HTTP unit', async () => {70- let unitCalls = 071- const statuses = []72- const errors = []73- const controller = {74- abortController: null,75- stage: { style: { setProperty() {} } },76- setStatus(message, state) { statuses.push({ message, state }) },77- handleRenderError: PreviewController.prototype.handleRenderError,78- onError(message) { errors.push(message) },79- async renderThroughUnit() { unitCalls += 1 },70+test('routes formerly client-rendered engines through HTTP units', () => {71+ for (const type of ['mermaid', 'bpmn', 'excalidraw', 'diagramsnet', 'tikz']) {72+ assert.equal(httpRendererUnitFor(type), `https://${type}.render.diagram.zip/v1/svg`)8073 }81-82- await PreviewController.prototype.performRender.call(controller, {83- type: 'mermaid',84- source: 'flowchart LR; A --> B',85- options: {},86- meta: {},87- presentation: {},88- renderKey: 'client-failure',89- requestNumber: 1,90- })91-92- assert.equal(unitCalls, 0)93- assert.equal(statuses.at(-1).state, 'idle')94- assert.equal(errors.length, 1)95- assert.equal(typeof errors[0], 'string')96-})97-98-test('does not send a failed diagrams.net renderer to an HTTP unit', async () => {99- let unitCalls = 0100- const statuses = []101- const errors = []102- const controller = {103- abortController: null,104- stage: { style: { setProperty() {} } },105- setStatus(message, state) { statuses.push({ message, state }) },106- handleRenderError: PreviewController.prototype.handleRenderError,107- onError(message) { errors.push(message) },108- async renderThroughUnit() { unitCalls += 1 },109- }110-111- await PreviewController.prototype.performRender.call(controller, {112- type: 'diagramsnet',113- source: '<mxGraphModel/>',114- options: {},115- meta: {},116- presentation: {},117- renderKey: 'diagramsnet-client-failure',118- requestNumber: 1,119- })120-121- assert.equal(unitCalls, 0)122- assert.equal(statuses.at(-1).state, 'idle')123- assert.equal(errors.length, 1)124- assert.equal(typeof errors[0], 'string')12574 })12675 12776 test('keeps render details out of the status hint', () => {
M apps/editor/test/renderer-units.test.js +9 -12
@@ -1,24 +1,24 @@
11 import assert from 'node:assert/strict'22 import test from 'node:test'3-import { CLIENT_RENDERER_IDS } from '../src/client-renderers.js'43 import { diagramTypes } from '../src/diagram-types.js'54 import { HTTP_RENDERER_UNITS, httpRendererUnitFor } from '../src/renderer-units.js'65 76 test('gives each HTTP renderer its own subdomain', () => {8- assert.equal(Object.keys(HTTP_RENDERER_UNITS).length, 25)7+ assert.equal(Object.keys(HTTP_RENDERER_UNITS).length, 30)98 for (const [engine, endpoint] of Object.entries(HTTP_RENDERER_UNITS)) {109 assert.equal(new URL(endpoint).hostname, `${engine}.render.diagram.zip`)1110 assert.equal(new URL(endpoint).pathname, '/v1/svg')1211 }1312 })1413 15-test('does not route client renderers through an HTTP unit', () => {14+test('routes every formerly client-rendered engine through its HTTP unit', () => {1615 assert.equal(httpRendererUnitFor('bytefield'), 'https://bytefield.render.diagram.zip/v1/svg')1716 assert.equal(httpRendererUnitFor('plantuml'), 'https://plantuml.render.diagram.zip/v1/svg')18- assert.equal(httpRendererUnitFor('mermaid'), null)19- assert.equal(httpRendererUnitFor('bpmn'), null)20- assert.equal(httpRendererUnitFor('excalidraw'), null)21- assert.equal(httpRendererUnitFor('diagramsnet'), null)17+ assert.equal(httpRendererUnitFor('mermaid'), 'https://mermaid.render.diagram.zip/v1/svg')18+ assert.equal(httpRendererUnitFor('bpmn'), 'https://bpmn.render.diagram.zip/v1/svg')19+ assert.equal(httpRendererUnitFor('excalidraw'), 'https://excalidraw.render.diagram.zip/v1/svg')20+ assert.equal(httpRendererUnitFor('diagramsnet'), 'https://diagramsnet.render.diagram.zip/v1/svg')21+ assert.equal(httpRendererUnitFor('tikz'), 'https://tikz.render.diagram.zip/v1/svg')2222 })2323 2424 test('does not invent an endpoint for an unknown renderer', () => {
@@ -27,9 +27,6 @@ test('does not invent an endpoint for an unknown renderer', () => {
2727 2828 test('assigns exactly one final renderer path to every diagram type', () => {2929 const http = Object.keys(HTTP_RENDERER_UNITS)30- const client = [...CLIENT_RENDERER_IDS]31- assert.equal(http.length, 25)32- assert.equal(client.length, 5)33- assert.deepEqual(new Set([...http, ...client]), new Set(diagramTypes.map(({ id }) => id)))34- assert.equal(http.some(engine => client.includes(engine)), false)30+ assert.equal(http.length, 30)31+ assert.deepEqual(new Set(http), new Set(diagramTypes.map(({ id }) => id)))3532 })