Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add cache for SVG generate by graphviz #348

Merged
merged 1 commit into from
Aug 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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