Skip to content

Commit

Permalink
Add cache for SVG generate by graphviz (#348)
Browse files Browse the repository at this point in the history
  • Loading branch information
IvanGoncharov authored Aug 8, 2023
1 parent 4ec0239 commit bdbcbf7
Show file tree
Hide file tree
Showing 11 changed files with 272 additions and 91 deletions.
1 change: 1 addition & 0 deletions cspell.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ overrides:
- css

words:
- graphviz
- svgr
- reactroot # FIXME https://github.com/facebook/react/issues/10971
- zoomer # FIXME
Expand Down
14 changes: 7 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 7 additions & 7 deletions src/components/GraphViewport.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 ||
Expand All @@ -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;
});
Expand Down
148 changes: 148 additions & 0 deletions src/graph/graphviz-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
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<string> {
const cacheKey = await this.generateCacheKey(dot);

if (cacheKey != null) {
try {
const cachedSVG = this._cache.get(cacheKey);
if (cachedSVG != null) {
console.log('graphql-voyager: SVG cached');
return decompressFromDataURL(cachedSVG);
}
} catch (err) {
console.warn('graphql-voyager: Can not read cache: ', err);
}
}

const svg = await this._renderString(dot);

if (cacheKey != null) {
try {
this._cache.set(cacheKey, await compressToDataURL(svg));
} catch (err) {
console.warn('graphql-voyager: Can not write cache: ', err);
}
}
return svg;
}

async generateCacheKey(dot: string): Promise<string | null> {
try {
const dotHash = await computeHash(dot);
return `worker:${VizWorkerHash}:dot:${dotHash}`;
} catch (err) {
console.warn('graphql-voyager: Can not generate cache key: ', err);
return null;
}
}

_renderString(src: string): Promise<string> {
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);
}
console.timeEnd('graphql-voyager: Rendering SVG');
resolve(result.value);
});

console.time('graphql-voyager: Rendering SVG');
const renderRequest: RenderRequest = { id, src };
this._worker.postMessage(renderRequest);
});
}
}

async function decompressFromDataURL(dataURL: string): Promise<string> {
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<string> {
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('graphql-voyager: Can not compress string: ', err);
return `data:text/plain;charset=utf-8,${encodeURIComponent(str)}`;
}
}

function blobToDataURL(blob: Blob): Promise<string> {
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<Blob> {
const response = new Response(stream, {
headers: { 'Content-Type': mimeType },
});
return response.blob();
}
81 changes: 13 additions & 68 deletions src/graph/svg-renderer.ts
Original file line number Diff line number Diff line change
@@ -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<RenderRequestListener> = [];
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<string> {
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(/<svg [^>]*>/, '$&' + RelayIconSvg);
Expand Down
2 changes: 1 addition & 1 deletion src/middleware/render-voyager-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export default function renderVoyagerPage(options: MiddlewareOptions) {
GraphQLVoyager.init(document.getElementById('voyager'), {
introspection,
displayOptions: ${JSON.stringify(displayOptions)},
})
});
})
</script>
</body>
Expand Down
13 changes: 13 additions & 0 deletions src/utils/compute-hash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
const textEncoder = new TextEncoder();

export async function computeHash(str: string): Promise<string> {
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;
}
Loading

0 comments on commit bdbcbf7

Please sign in to comment.