6
6
7
7
import * as workerthreads from 'worker_threads' ;
8
8
import * as pathlib from 'path' ;
9
+ import * as fs from 'fs' ;
10
+
11
+ const cachedHighlightsDir = pathlib . resolve (
12
+ __dirname ,
13
+ '../../.highlights_cache/'
14
+ ) ;
9
15
10
16
export type WorkerMessage = HandshakeMessage | Render | Shutdown ;
11
17
@@ -32,6 +38,38 @@ export interface Shutdown {
32
38
type : 'shutdown' ;
33
39
}
34
40
41
+ // Create a cache key for the highlighted strings. This is a
42
+ // simple digest build from a DJB2-ish hash modified from:
43
+ // https://github.com/darkskyapp/string-hash/blob/master/index.js
44
+ // This is modified from @lit -labs/ssr-client.
45
+ // Goals:
46
+ // - Extremely low collision rate. We may not be able to detect collisions.
47
+ // - Extremely fast.
48
+ // - Extremely small code size.
49
+ // - Safe to include in HTML comment text or attribute value.
50
+ // - Easily specifiable and implementable in multiple languages.
51
+ // We don't care about cryptographic suitability.
52
+ const digestToFileName = ( stringToDigest : string ) => {
53
+ // Number of 32 bit elements to use to create template digests
54
+ const digestSize = 5 ;
55
+ const hashes = new Uint32Array ( digestSize ) . fill ( 5381 ) ;
56
+ for ( let i = 0 ; i < stringToDigest . length ; i ++ ) {
57
+ hashes [ i % digestSize ] =
58
+ ( hashes [ i % digestSize ] * 33 ) ^ stringToDigest . charCodeAt ( i ) ;
59
+ }
60
+ const str = String . fromCharCode ( ...new Uint8Array ( hashes . buffer ) ) ;
61
+ return (
62
+ Buffer . from ( str , 'binary' )
63
+ . toString ( 'base64' )
64
+ // These characters do not play well in file names. Replace with
65
+ // underscores.
66
+ . replace ( / [ < > : " ' / \\ | ? * ] / g, '_' )
67
+ ) ;
68
+ } ;
69
+
70
+ const createUniqueFileNameKey = ( lang : string , code : string ) =>
71
+ digestToFileName ( `[${ lang } ]:${ code } ` ) ;
72
+
35
73
export class BlockingRenderer {
36
74
/** Worker that performs rendering. */
37
75
private worker : workerthreads . Worker ;
@@ -45,7 +83,20 @@ export class BlockingRenderer {
45
83
private exited = false ;
46
84
private renderTimeout : number ;
47
85
48
- constructor ( { renderTimeout = 60_000 , maxHtmlBytes = 1024 * 1024 } = { } ) {
86
+ /**
87
+ * Spawning a headless browser to syntax highlight code is expensive and slows
88
+ * down the edit/refresh loop during development. When developing, cache the
89
+ * syntax highlighted DOM in the filesystem so it can be retrieved if
90
+ * previously seen.
91
+ */
92
+ private isDevMode = false ;
93
+
94
+ constructor ( {
95
+ renderTimeout = 60_000 ,
96
+ maxHtmlBytes = 1024 * 1024 ,
97
+ isDevMode = false ,
98
+ } = { } ) {
99
+ this . isDevMode = isDevMode ;
49
100
this . renderTimeout = renderTimeout ;
50
101
this . sharedHtml = new Uint8Array ( new SharedArrayBuffer ( maxHtmlBytes ) ) ;
51
102
this . worker = new workerthreads . Worker (
@@ -70,6 +121,15 @@ export class BlockingRenderer {
70
121
htmlBuffer : this . sharedHtml ,
71
122
notify : this . sharedNotify ,
72
123
} ) ;
124
+ try {
125
+ fs . mkdirSync ( cachedHighlightsDir ) ;
126
+ } catch ( error ) {
127
+ if ( ( error as { code : string } ) . code === 'EEXIST' ) {
128
+ // Directory already exists.
129
+ } else {
130
+ throw error ;
131
+ }
132
+ }
73
133
}
74
134
75
135
async stop ( ) : Promise < void > {
@@ -82,7 +142,46 @@ export class BlockingRenderer {
82
142
} ) ;
83
143
}
84
144
145
+ private getCachedRender ( cachedFileName : string ) : string | null {
146
+ const absoluteFilePath = pathlib . resolve (
147
+ cachedHighlightsDir ,
148
+ cachedFileName
149
+ ) ;
150
+ if ( fs . existsSync ( absoluteFilePath ) ) {
151
+ return fs . readFileSync ( absoluteFilePath , { encoding : 'utf8' } ) ;
152
+ }
153
+ return null ;
154
+ }
155
+
156
+ private writeCachedRender ( cachedFileName : string , html : string ) {
157
+ const absoluteFilePath = pathlib . resolve (
158
+ cachedHighlightsDir ,
159
+ cachedFileName
160
+ ) ;
161
+ fs . writeFileSync ( absoluteFilePath , html ) ;
162
+ }
163
+
85
164
render ( lang : 'js' | 'ts' | 'html' | 'css' , code : string ) : { html : string } {
165
+ if ( ! this . isDevMode ) {
166
+ // In production, skip all caching.
167
+ return this . renderWithWorker ( lang , code ) ;
168
+ }
169
+ // In dev mode, speed up the edit-refresh loop by caching the syntax
170
+ // highlighted code.
171
+ const cachedFileName = createUniqueFileNameKey ( lang , code ) ;
172
+ const cachedResult = this . getCachedRender ( cachedFileName ) ;
173
+ if ( cachedResult !== null ) {
174
+ return { html : cachedResult } ;
175
+ }
176
+ const { html} = this . renderWithWorker ( lang , code ) ;
177
+ this . writeCachedRender ( cachedFileName , html ) ;
178
+ return { html} ;
179
+ }
180
+
181
+ private renderWithWorker (
182
+ lang : 'js' | 'ts' | 'html' | 'css' ,
183
+ code : string
184
+ ) : { html : string } {
86
185
if ( this . exited ) {
87
186
throw new Error ( 'BlockingRenderer worker has already exited' ) ;
88
187
}
0 commit comments