Skip to content

Commit bdbcbf7

Browse files
Add cache for SVG generate by graphviz (#348)
1 parent 4ec0239 commit bdbcbf7

11 files changed

+272
-91
lines changed

cspell.yml

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ overrides:
1616
- css
1717

1818
words:
19+
- graphviz
1920
- svgr
2021
- reactroot # FIXME https://github.com/facebook/react/issues/10971
2122
- zoomer # FIXME

package-lock.json

+7-7
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
"@playwright/test": "1.32.0",
5757
"@svgr/webpack": "6.5.1",
5858
"@types/commonmark": "0.27.5",
59-
"@types/node": "18.15.5",
59+
"@types/node": "20.4.8",
6060
"@types/react": "18.0.26",
6161
"@types/react-dom": "18.0.10",
6262
"@types/webpack-node-externals": "^3.0.0",

src/components/GraphViewport.tsx

+7-7
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
import { Component } from 'react';
22

3-
import { SVGRender, TypeGraph, Viewport } from './../graph/';
3+
import { renderSvg, TypeGraph, Viewport } from './../graph/';
44
import LoadingAnimation from './utils/LoadingAnimation';
55
import { VoyagerDisplayOptions } from './Voyager';
66

7-
const svgRenderer = new SVGRender();
8-
97
interface GraphViewportProps {
108
typeGraph: TypeGraph | null;
119
displayOptions: VoyagerDisplayOptions;
@@ -108,8 +106,7 @@ export default class GraphViewport extends Component<
108106
this._currentDisplayOptions = displayOptions;
109107

110108
const { onSelectNode, onSelectEdge } = this.props;
111-
svgRenderer
112-
.renderSvg(typeGraph, displayOptions)
109+
renderSvg(typeGraph, displayOptions)
113110
.then((svg) => {
114111
if (
115112
typeGraph !== this._currentTypeGraph ||
@@ -128,11 +125,14 @@ export default class GraphViewport extends Component<
128125
);
129126
this.setState({ svgViewport });
130127
})
131-
.catch((error) => {
128+
.catch((rawError) => {
132129
this._currentTypeGraph = null;
133130
this._currentDisplayOptions = null;
134131

135-
error.message = error.message || 'Unknown error';
132+
const error =
133+
rawError instanceof Error
134+
? rawError
135+
: new Error('Unknown error: ' + String(rawError));
136136
this.setState(() => {
137137
throw error;
138138
});

src/graph/graphviz-worker.ts

+148
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import {
2+
RenderRequest,
3+
RenderResponse,
4+
RenderResult,
5+
VizWorkerHash,
6+
VizWorkerSource,
7+
// eslint-disable-next-line import/no-unresolved
8+
} from '../../worker/voyager.worker';
9+
import { computeHash } from '../utils/compute-hash';
10+
import { LocalStorageLRUCache } from '../utils/local-storage-lru-cache';
11+
12+
export class VizWorker {
13+
private _cache = new LocalStorageLRUCache({
14+
localStorageKey: 'VoyagerSVGCache',
15+
maxSize: 10,
16+
});
17+
private _worker: Worker;
18+
private _listeners: Array<(result: RenderResult) => void> = [];
19+
20+
constructor() {
21+
const blob = new Blob([VizWorkerSource], {
22+
type: 'application/javascript',
23+
});
24+
const url = URL.createObjectURL(blob);
25+
26+
this._worker = new Worker(url, { name: 'graphql-voyager-worker' });
27+
this._worker.addEventListener('message', (event) => {
28+
const { id, result } = event.data as RenderResponse;
29+
30+
this._listeners[id](result);
31+
delete this._listeners[id];
32+
});
33+
}
34+
35+
async renderString(dot: string): Promise<string> {
36+
const cacheKey = await this.generateCacheKey(dot);
37+
38+
if (cacheKey != null) {
39+
try {
40+
const cachedSVG = this._cache.get(cacheKey);
41+
if (cachedSVG != null) {
42+
console.log('graphql-voyager: SVG cached');
43+
return decompressFromDataURL(cachedSVG);
44+
}
45+
} catch (err) {
46+
console.warn('graphql-voyager: Can not read cache: ', err);
47+
}
48+
}
49+
50+
const svg = await this._renderString(dot);
51+
52+
if (cacheKey != null) {
53+
try {
54+
this._cache.set(cacheKey, await compressToDataURL(svg));
55+
} catch (err) {
56+
console.warn('graphql-voyager: Can not write cache: ', err);
57+
}
58+
}
59+
return svg;
60+
}
61+
62+
async generateCacheKey(dot: string): Promise<string | null> {
63+
try {
64+
const dotHash = await computeHash(dot);
65+
return `worker:${VizWorkerHash}:dot:${dotHash}`;
66+
} catch (err) {
67+
console.warn('graphql-voyager: Can not generate cache key: ', err);
68+
return null;
69+
}
70+
}
71+
72+
_renderString(src: string): Promise<string> {
73+
return new Promise((resolve, reject) => {
74+
const id = this._listeners.length;
75+
76+
this._listeners.push(function (result): void {
77+
if ('error' in result) {
78+
const { error } = result;
79+
const e = new Error(error.message);
80+
if (error.fileName) (e as any).fileName = error.fileName;
81+
if (error.lineNumber) (e as any).lineNumber = error.lineNumber;
82+
if (error.stack) (e as any).stack = error.stack;
83+
return reject(e);
84+
}
85+
console.timeEnd('graphql-voyager: Rendering SVG');
86+
resolve(result.value);
87+
});
88+
89+
console.time('graphql-voyager: Rendering SVG');
90+
const renderRequest: RenderRequest = { id, src };
91+
this._worker.postMessage(renderRequest);
92+
});
93+
}
94+
}
95+
96+
async function decompressFromDataURL(dataURL: string): Promise<string> {
97+
const response = await fetch(dataURL);
98+
const blob = await response.blob();
99+
switch (blob.type) {
100+
case 'application/gzip': {
101+
// @ts-expect-error DecompressionStream is missing from DOM types
102+
const stream = blob.stream().pipeThrough(new DecompressionStream('gzip'));
103+
const decompressedBlob = await streamToBlob(stream, 'text/plain');
104+
return decompressedBlob.text();
105+
}
106+
case 'text/plain':
107+
return blob.text();
108+
default:
109+
throw new Error('Can not convert data url with MIME type:' + blob.type);
110+
}
111+
}
112+
113+
async function compressToDataURL(str: string): Promise<string> {
114+
try {
115+
const blob = new Blob([str], { type: 'text/plain' });
116+
// @ts-expect-error CompressionStream is missing from DOM types
117+
const stream = blob.stream().pipeThrough(new CompressionStream('gzip'));
118+
const compressedBlob = await streamToBlob(stream, 'application/gzip');
119+
return blobToDataURL(compressedBlob);
120+
} catch (err) {
121+
console.warn('graphql-voyager: Can not compress string: ', err);
122+
return `data:text/plain;charset=utf-8,${encodeURIComponent(str)}`;
123+
}
124+
}
125+
126+
function blobToDataURL(blob: Blob): Promise<string> {
127+
const fileReader = new FileReader();
128+
129+
return new Promise((resolve, reject) => {
130+
try {
131+
fileReader.onload = function (event) {
132+
// eslint-disable-next-line @typescript-eslint/no-base-to-string
133+
const dataURL = event.target!.result!.toString();
134+
resolve(dataURL);
135+
};
136+
fileReader.readAsDataURL(blob);
137+
} catch (err) {
138+
reject(err);
139+
}
140+
});
141+
}
142+
143+
function streamToBlob(stream: ReadableStream, mimeType: string): Promise<Blob> {
144+
const response = new Response(stream, {
145+
headers: { 'Content-Type': mimeType },
146+
});
147+
return response.blob();
148+
}

src/graph/svg-renderer.ts

+13-68
Original file line numberDiff line numberDiff line change
@@ -1,81 +1,26 @@
1-
// eslint-disable-next-line import/no-unresolved
2-
import VizWorker from '../../worker/voyager.worker.js';
31
import { VoyagerDisplayOptions } from '../components/Voyager';
42
import { stringToSvg } from '../utils/';
53
import { getDot } from './dot';
4+
import { VizWorker } from './graphviz-worker';
65
import { TypeGraph } from './type-graph';
76

7+
const vizWorker = new VizWorker();
8+
9+
export async function renderSvg(
10+
typeGraph: TypeGraph,
11+
displayOptions: VoyagerDisplayOptions,
12+
) {
13+
const dot = getDot(typeGraph, displayOptions);
14+
const rawSVG = await vizWorker.renderString(dot);
15+
const svg = preprocessVizSVG(rawSVG);
16+
return svg;
17+
}
18+
819
const RelayIconSvg = require('!!svg-as-symbol-loader?id=RelayIcon!../components/icons/relay-icon.svg');
920
const DeprecatedIconSvg = require('!!svg-as-symbol-loader?id=DeprecatedIcon!../components/icons/deprecated-icon.svg');
1021
const svgNS = 'http://www.w3.org/2000/svg';
1122
const xlinkNS = 'http://www.w3.org/1999/xlink';
1223

13-
interface SerializedError {
14-
message: string;
15-
lineNumber?: number;
16-
fileName?: string;
17-
stack?: string;
18-
}
19-
20-
type RenderRequestListener = (result: RenderResult) => void;
21-
22-
interface RenderRequest {
23-
id: number;
24-
src: string;
25-
}
26-
27-
interface RenderResponse {
28-
id: number;
29-
result: RenderResult;
30-
}
31-
type RenderResult = { error: SerializedError } | { value: string };
32-
33-
export class SVGRender {
34-
private _worker: Worker;
35-
36-
private _listeners: Array<RenderRequestListener> = [];
37-
constructor() {
38-
this._worker = VizWorker;
39-
40-
this._worker.addEventListener('message', (event) => {
41-
const { id, result } = event.data as RenderResponse;
42-
43-
this._listeners[id](result);
44-
delete this._listeners[id];
45-
});
46-
}
47-
48-
async renderSvg(typeGraph: TypeGraph, displayOptions: VoyagerDisplayOptions) {
49-
console.time('Rendering Graph');
50-
const dot = getDot(typeGraph, displayOptions);
51-
const rawSVG = await this._renderString(dot);
52-
const svg = preprocessVizSVG(rawSVG);
53-
console.timeEnd('Rendering Graph');
54-
return svg;
55-
}
56-
57-
_renderString(src: string): Promise<string> {
58-
return new Promise((resolve, reject) => {
59-
const id = this._listeners.length;
60-
61-
this._listeners.push(function (result): void {
62-
if ('error' in result) {
63-
const { error } = result;
64-
const e = new Error(error.message);
65-
if (error.fileName) (e as any).fileName = error.fileName;
66-
if (error.lineNumber) (e as any).lineNumber = error.lineNumber;
67-
if (error.stack) (e as any).stack = error.stack;
68-
return reject(e);
69-
}
70-
resolve(result.value);
71-
});
72-
73-
const renderRequest: RenderRequest = { id, src };
74-
this._worker.postMessage(renderRequest);
75-
});
76-
}
77-
}
78-
7924
function preprocessVizSVG(svgString: string) {
8025
//Add Relay and Deprecated icons
8126
svgString = svgString.replace(/<svg [^>]*>/, '$&' + RelayIconSvg);

src/middleware/render-voyager-page.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export default function renderVoyagerPage(options: MiddlewareOptions) {
5555
GraphQLVoyager.init(document.getElementById('voyager'), {
5656
introspection,
5757
displayOptions: ${JSON.stringify(displayOptions)},
58-
})
58+
});
5959
})
6060
</script>
6161
</body>

src/utils/compute-hash.ts

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
const textEncoder = new TextEncoder();
2+
3+
export async function computeHash(str: string): Promise<string> {
4+
const data = textEncoder.encode(str);
5+
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
6+
7+
const hashArray = Array.from(new Uint8Array(hashBuffer));
8+
const hashHex = hashArray
9+
.map((b) => b.toString(16).padStart(2, '0'))
10+
.join('');
11+
12+
return hashHex;
13+
}

0 commit comments

Comments
 (0)