clone

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

main
diagramzip / renderers / browser-run / src / executor.ts
executor.ts 4.9K
1import puppeteer from '@cloudflare/puppeteer'2import type { Browser, Page, BrowserWorker } from '@cloudflare/puppeteer'3import { CHANNEL, FRAME_URLS, LIMITS, type RenderRequest, type RenderResponse } from './contracts'45export interface BrowserExecutor {6 render(request: RenderRequest): Promise<RenderResponse>7 close(): Promise<void>8}910export interface PageLike {11 goto(url: string, options?: { waitUntil?: 'load' | 'domcontentloaded' | 'networkidle0' | 'networkidle2' }): Promise<unknown>12 evaluate<T>(pageFunction: (payload: unknown) => Promise<T>, payload: unknown): Promise<T>13}1415export interface BrowserLike {16 newPage(): Promise<PageLike>17 close(): Promise<void>18 isConnected(): boolean19}2021/** This function is serialized by Puppeteer and executed in the renderer page. */22export async function browserHarness(payload: unknown): Promise<unknown> {23 return new Promise((resolve) => {24 const pageWindow = globalThis as unknown as {25 addEventListener(type: string, listener: (event: { source: unknown; data: unknown }) => void): void26 removeEventListener(type: string, listener: (event: { source: unknown; data: unknown }) => void): void27 postMessage(message: unknown, targetOrigin: string): void28 }29 const request = payload as { engine: string; requestId: string; source: string }30 const channel = 'diagram.zip:renderer:v1'31 let settled = false32 const finish = (value: unknown) => {33 if (!settled) { settled = true; pageWindow.removeEventListener('message', onMessage); resolve(value) }34 }35 const onMessage = (event: { source: unknown; data: unknown }) => {36 const message = event.data37 if (event.source !== pageWindow || (message as Record<string, unknown>)?.channel !== channel || (message as Record<string, unknown>)?.type !== 'result' || (message as Record<string, unknown>)?.requestId !== request.requestId) return38 finish(message)39 }40 pageWindow.addEventListener('message', onMessage)41 pageWindow.postMessage({ channel, type: 'render', engine: request.engine, requestId: request.requestId, source: request.source }, '*')42 })43}4445export function createPageExecutor(page: PageLike, timeoutMs = LIMITS.timeoutMs, initialEngine?: string): BrowserExecutor {46 let loadedEngine: string | undefined = initialEngine47 return {48 async render(request) {49 if (loadedEngine !== request.engine) {50 await page.goto(FRAME_URLS[request.engine], { waitUntil: 'domcontentloaded' })51 loadedEngine = request.engine52 }53 const result = await Promise.race([54 page.evaluate<RenderResponse>(browserHarness as unknown as (payload: unknown) => Promise<RenderResponse>, request),55 new Promise<never>((_, reject) => setTimeout(() => reject(new Error('Renderer timed out.')), timeoutMs)),56 ])57 if (!isFrameResponse(result)) throw new Error('Renderer returned an invalid response.')58 if (result.ok && result.svg.length > LIMITS.output) throw new Error('Renderer SVG output is too large.')59 return result60 },61 async close() { /* Page ownership is managed by the browser session. */ },62 }63}6465function isFrameResponse(value: unknown): value is RenderResponse {66 if (typeof value !== 'object' || value === null) return false67 const result = value as Record<string, unknown>68 if (result.ok === false) return typeof result.error === 'string' && result.error.length > 0 && result.error.length <= 50069 return result.ok === true70 && typeof result.svg === 'string'71 && typeof result.version === 'string' && result.version.length > 072 && typeof result.build === 'string' && result.build.length > 073 && Array.isArray(result.pipeline)74 && result.pipeline.every(item => typeof item === 'string')75}7677export class PuppeteerExecutor implements BrowserExecutor {78 private browser?: Browser79 private page?: Page80 private loadedEngine?: string8182 constructor(private readonly endpoint: BrowserWorker) {}8384 private async pageFor(engine: RenderRequest['engine']): Promise<Page> {85 if (!this.browser?.isConnected()) {86 this.browser = await puppeteer.launch(this.endpoint, {87 keep_alive: 60_000,88 guardrails: {89 allowedDomains: ['mermaid.render.diagram.zip', 'diagramsnet.render.diagram.zip'],90 },91 })92 this.page = undefined93 this.loadedEngine = undefined94 }95 if (!this.page) this.page = await this.browser!.newPage()96 if (this.loadedEngine !== engine) {97 await this.page.goto(FRAME_URLS[engine], { waitUntil: 'domcontentloaded' })98 this.loadedEngine = engine99 }100 return this.page101 }102103 async render(request: RenderRequest): Promise<RenderResponse> {104 const page = await this.pageFor(request.engine)105 return createPageExecutor(page, LIMITS.timeoutMs, this.loadedEngine).render(request)106 }107108 async close(): Promise<void> {109 if (this.browser?.isConnected()) await this.browser.close()110 this.browser = undefined111 this.page = undefined112 this.loadedEngine = undefined113 }114}