diff --git a/package-lock.json b/package-lock.json index d2af85b8..0829740a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "@playwright/test": "1.32.0", "@svgr/webpack": "6.5.1", "@types/commonmark": "0.27.5", - "@types/node": "18.15.5", + "@types/node": "20.4.8", "@types/react": "18.0.26", "@types/react-dom": "18.0.10", "@types/webpack-node-externals": "^3.0.0", @@ -3497,9 +3497,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "18.15.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.5.tgz", - "integrity": "sha512-Ark2WDjjZO7GmvsyFFf81MXuGTA/d6oP38anyxWOL6EREyBKAxKoFHwBhaZxCfLRLpO8JgVXwqOwSwa7jRcjew==", + "version": "20.4.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.8.tgz", + "integrity": "sha512-0mHckf6D2DiIAzh8fM8f3HQCvMKDpK94YQ0DSVkfWTG9BZleYIWudw9cJxX8oCk9bM+vAkDyujDV6dmKHbvQpg==", "dev": true }, "node_modules/@types/parse-json": { @@ -18330,9 +18330,9 @@ "dev": true }, "@types/node": { - "version": "18.15.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.5.tgz", - "integrity": "sha512-Ark2WDjjZO7GmvsyFFf81MXuGTA/d6oP38anyxWOL6EREyBKAxKoFHwBhaZxCfLRLpO8JgVXwqOwSwa7jRcjew==", + "version": "20.4.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.8.tgz", + "integrity": "sha512-0mHckf6D2DiIAzh8fM8f3HQCvMKDpK94YQ0DSVkfWTG9BZleYIWudw9cJxX8oCk9bM+vAkDyujDV6dmKHbvQpg==", "dev": true }, "@types/parse-json": { diff --git a/package.json b/package.json index b35971f8..499371ab 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "@playwright/test": "1.32.0", "@svgr/webpack": "6.5.1", "@types/commonmark": "0.27.5", - "@types/node": "18.15.5", + "@types/node": "20.4.8", "@types/react": "18.0.26", "@types/react-dom": "18.0.10", "@types/webpack-node-externals": "^3.0.0", diff --git a/src/components/GraphViewport.tsx b/src/components/GraphViewport.tsx index f259b8ce..bef17d2f 100644 --- a/src/components/GraphViewport.tsx +++ b/src/components/GraphViewport.tsx @@ -1,11 +1,9 @@ import { Component } from 'react'; -import { SVGRender, TypeGraph, Viewport } from './../graph/'; +import { renderSvg, TypeGraph, Viewport } from './../graph/'; import LoadingAnimation from './utils/LoadingAnimation'; import { VoyagerDisplayOptions } from './Voyager'; -const svgRenderer = new SVGRender(); - interface GraphViewportProps { typeGraph: TypeGraph | null; displayOptions: VoyagerDisplayOptions; @@ -108,8 +106,7 @@ export default class GraphViewport extends Component< this._currentDisplayOptions = displayOptions; const { onSelectNode, onSelectEdge } = this.props; - svgRenderer - .renderSvg(typeGraph, displayOptions) + renderSvg(typeGraph, displayOptions) .then((svg) => { if ( typeGraph !== this._currentTypeGraph || @@ -128,11 +125,14 @@ export default class GraphViewport extends Component< ); this.setState({ svgViewport }); }) - .catch((error) => { + .catch((rawError) => { this._currentTypeGraph = null; this._currentDisplayOptions = null; - error.message = error.message || 'Unknown error'; + const error = + rawError instanceof Error + ? rawError + : new Error('Unknown error: ' + String(rawError)); this.setState(() => { throw error; }); diff --git a/src/graph/graphviz-worker.ts b/src/graph/graphviz-worker.ts new file mode 100644 index 00000000..48ec70ee --- /dev/null +++ b/src/graph/graphviz-worker.ts @@ -0,0 +1,135 @@ +import { + RenderRequest, + RenderResponse, + RenderResult, + VizWorkerHash, + VizWorkerSource, + // eslint-disable-next-line import/no-unresolved +} from '../../worker/voyager.worker'; +import { computeHash } from '../utils/compute-hash'; +import { LocalStorageLRUCache } from '../utils/local-storage-lru-cache'; + +export class VizWorker { + private _cache = new LocalStorageLRUCache({ + localStorageKey: 'VoyagerSVGCache', + maxSize: 10, + }); + private _worker: Worker; + private _listeners: Array<(result: RenderResult) => void> = []; + + constructor() { + const blob = new Blob([VizWorkerSource], { + type: 'application/javascript', + }); + const url = URL.createObjectURL(blob); + + this._worker = new Worker(url, { name: 'graphql-voyager-worker' }); + this._worker.addEventListener('message', (event) => { + const { id, result } = event.data as RenderResponse; + + this._listeners[id](result); + delete this._listeners[id]; + }); + } + + async renderString(dot: string): Promise { + const dotHash = await computeHash(dot); + const cacheKey = `worker:${VizWorkerHash}:dot:${dotHash}`; + + try { + const cachedSVG = this._cache.get(cacheKey); + if (cachedSVG != null) { + console.log('SVG cached'); + return decompressFromDataURL(cachedSVG); + } + } catch (err) { + console.warn('Can not read graphql-voyager cache: ', err); + } + + console.time('Rendering SVG'); + const svg = await this._renderString(dot); + console.timeEnd('Rendering SVG'); + + try { + this._cache.set(cacheKey, await compressToDataURL(svg)); + } catch (err) { + console.warn('Can not write graphql-voyager cache: ', err); + } + return svg; + } + + _renderString(src: string): Promise { + return new Promise((resolve, reject) => { + const id = this._listeners.length; + + this._listeners.push(function (result): void { + if ('error' in result) { + const { error } = result; + const e = new Error(error.message); + if (error.fileName) (e as any).fileName = error.fileName; + if (error.lineNumber) (e as any).lineNumber = error.lineNumber; + if (error.stack) (e as any).stack = error.stack; + return reject(e); + } + resolve(result.value); + }); + + const renderRequest: RenderRequest = { id, src }; + this._worker.postMessage(renderRequest); + }); + } +} + +async function decompressFromDataURL(dataURL: string): Promise { + const response = await fetch(dataURL); + const blob = await response.blob(); + switch (blob.type) { + case 'application/gzip': { + // @ts-expect-error DecompressionStream is missing from DOM types + const stream = blob.stream().pipeThrough(new DecompressionStream('gzip')); + const decompressedBlob = await streamToBlob(stream, 'text/plain'); + return decompressedBlob.text(); + } + case 'text/plain': + return blob.text(); + default: + throw new Error('Can not convert data url with MIME type:' + blob.type); + } +} + +async function compressToDataURL(str: string): Promise { + try { + const blob = new Blob([str], { type: 'text/plain' }); + // @ts-expect-error CompressionStream is missing from DOM types + const stream = blob.stream().pipeThrough(new CompressionStream('gzip')); + const compressedBlob = await streamToBlob(stream, 'application/gzip'); + return blobToDataURL(compressedBlob); + } catch (err) { + console.warn('Can not compress string: ', err); + return `data:text/plain;charset=utf-8,${encodeURIComponent(str)}`; + } +} + +function blobToDataURL(blob: Blob): Promise { + const fileReader = new FileReader(); + + return new Promise((resolve, reject) => { + try { + fileReader.onload = function (event) { + // eslint-disable-next-line @typescript-eslint/no-base-to-string + const dataURL = event.target!.result!.toString(); + resolve(dataURL); + }; + fileReader.readAsDataURL(blob); + } catch (err) { + reject(err); + } + }); +} + +function streamToBlob(stream: ReadableStream, mimeType: string): Promise { + const response = new Response(stream, { + headers: { 'Content-Type': mimeType }, + }); + return response.blob(); +} diff --git a/src/graph/svg-renderer.ts b/src/graph/svg-renderer.ts index 44f6c690..488efe59 100644 --- a/src/graph/svg-renderer.ts +++ b/src/graph/svg-renderer.ts @@ -1,81 +1,26 @@ -// eslint-disable-next-line import/no-unresolved -import VizWorker from '../../worker/voyager.worker.js'; import { VoyagerDisplayOptions } from '../components/Voyager'; import { stringToSvg } from '../utils/'; import { getDot } from './dot'; +import { VizWorker } from './graphviz-worker'; import { TypeGraph } from './type-graph'; +const vizWorker = new VizWorker(); + +export async function renderSvg( + typeGraph: TypeGraph, + displayOptions: VoyagerDisplayOptions, +) { + const dot = getDot(typeGraph, displayOptions); + const rawSVG = await vizWorker.renderString(dot); + const svg = preprocessVizSVG(rawSVG); + return svg; +} + const RelayIconSvg = require('!!svg-as-symbol-loader?id=RelayIcon!../components/icons/relay-icon.svg'); const DeprecatedIconSvg = require('!!svg-as-symbol-loader?id=DeprecatedIcon!../components/icons/deprecated-icon.svg'); const svgNS = 'http://www.w3.org/2000/svg'; const xlinkNS = 'http://www.w3.org/1999/xlink'; -interface SerializedError { - message: string; - lineNumber?: number; - fileName?: string; - stack?: string; -} - -type RenderRequestListener = (result: RenderResult) => void; - -interface RenderRequest { - id: number; - src: string; -} - -interface RenderResponse { - id: number; - result: RenderResult; -} -type RenderResult = { error: SerializedError } | { value: string }; - -export class SVGRender { - private _worker: Worker; - - private _listeners: Array = []; - constructor() { - this._worker = VizWorker; - - this._worker.addEventListener('message', (event) => { - const { id, result } = event.data as RenderResponse; - - this._listeners[id](result); - delete this._listeners[id]; - }); - } - - async renderSvg(typeGraph: TypeGraph, displayOptions: VoyagerDisplayOptions) { - console.time('Rendering Graph'); - const dot = getDot(typeGraph, displayOptions); - const rawSVG = await this._renderString(dot); - const svg = preprocessVizSVG(rawSVG); - console.timeEnd('Rendering Graph'); - return svg; - } - - _renderString(src: string): Promise { - return new Promise((resolve, reject) => { - const id = this._listeners.length; - - this._listeners.push(function (result): void { - if ('error' in result) { - const { error } = result; - const e = new Error(error.message); - if (error.fileName) (e as any).fileName = error.fileName; - if (error.lineNumber) (e as any).lineNumber = error.lineNumber; - if (error.stack) (e as any).stack = error.stack; - return reject(e); - } - resolve(result.value); - }); - - const renderRequest: RenderRequest = { id, src }; - this._worker.postMessage(renderRequest); - }); - } -} - function preprocessVizSVG(svgString: string) { //Add Relay and Deprecated icons svgString = svgString.replace(/]*>/, '$&' + RelayIconSvg); diff --git a/src/middleware/render-voyager-page.ts b/src/middleware/render-voyager-page.ts index 4954592f..00baa590 100644 --- a/src/middleware/render-voyager-page.ts +++ b/src/middleware/render-voyager-page.ts @@ -55,7 +55,7 @@ export default function renderVoyagerPage(options: MiddlewareOptions) { GraphQLVoyager.init(document.getElementById('voyager'), { introspection, displayOptions: ${JSON.stringify(displayOptions)}, - }) + }); }) diff --git a/src/utils/compute-hash.ts b/src/utils/compute-hash.ts new file mode 100644 index 00000000..5b0dab25 --- /dev/null +++ b/src/utils/compute-hash.ts @@ -0,0 +1,13 @@ +const textEncoder = new TextEncoder(); + +export async function computeHash(str: string): Promise { + const data = textEncoder.encode(str); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + + return hashHex; +} diff --git a/src/utils/local-storage-lru-cache.ts b/src/utils/local-storage-lru-cache.ts new file mode 100644 index 00000000..b398ab9e --- /dev/null +++ b/src/utils/local-storage-lru-cache.ts @@ -0,0 +1,56 @@ +export class LocalStorageLRUCache { + private _localStorageKey; + private _maxSize; + + constructor(options: { localStorageKey: string; maxSize: number }) { + this._localStorageKey = options.localStorageKey; + this._maxSize = options.maxSize; + } + + public set(key: string, value: string): void { + const lru = this.readLRU(); + lru.delete(key); + lru.set(key, value); + this.writeLRU(lru); + } + + public get(key: string): string | null { + const lru = this.readLRU(); + const cachedValue = lru.get(key); + if (cachedValue === undefined) { + return null; + } + + lru.delete(key); + lru.set(key, cachedValue); + this.writeLRU(lru); + return cachedValue; + } + + private readLRU(): Map { + const rawData = localStorage.getItem(this._localStorageKey); + const data = JSON.parse(rawData ?? '{}'); + return new Map(Array.isArray(data) ? data : []); + } + + private writeLRU(lru: Map): void { + let maxSize = this._maxSize; + for (;;) { + try { + const trimmedPairs = Array.from(lru).slice(-maxSize); + const rawData = JSON.stringify(trimmedPairs); + localStorage.setItem(this._localStorageKey, rawData); + this._maxSize = maxSize; + break; + } catch (error) { + if (maxSize <= 1) { + throw error; + } + console.warn( + `Can't write LRU cache with ${maxSize} entries. Retrying...`, + ); + maxSize -= 1; + } + } + } +} diff --git a/worker/bundle.js b/worker/bundle.js index b6847436..586296fe 100644 --- a/worker/bundle.js +++ b/worker/bundle.js @@ -1,13 +1,13 @@ const fs = require('node:fs'); const assert = require('node:assert'); +const crypto = require('node:crypto'); const stdin = fs.readFileSync(0, 'utf-8'); +const hash = crypto.createHash('sha256').update(stdin).digest('hex'); + assert(stdin.includes('`') === false); const stdout = ` -const source = String.raw \`${stdin}\`; -const blob = new Blob([source], { type: 'application/javascript' }) -const url = URL.createObjectURL(blob); -const VizWorker = new Worker(url); -export default VizWorker; +export const VizWorkerSource = String.raw \`${stdin}\`; +export const VizWorkerHash = \`${hash}\`; `; fs.writeFileSync(1, stdout.trim()); diff --git a/worker/voyager.worker.d.ts b/worker/voyager.worker.d.ts index dcea02b4..8ddc4286 100644 --- a/worker/voyager.worker.d.ts +++ b/worker/voyager.worker.d.ts @@ -1,3 +1,21 @@ // Dummy file to make tsc happy, real file is generated into 'worker-dist' folder -declare const Module: Worker; -export default Module; +export const VizWorkerSource: string; +export const VizWorkerHash: string; + +interface SerializedError { + message: string; + lineNumber?: number; + fileName?: string; + stack?: string; +} + +interface RenderRequest { + id: number; + src: string; +} + +interface RenderResponse { + id: number; + result: RenderResult; +} +type RenderResult = { error: SerializedError } | { value: string };