From 2993f533d3b8b9274eef0b7818ac5dbf63ad0462 Mon Sep 17 00:00:00 2001 From: Andrew Jakubowicz Date: Wed, 18 Oct 2023 12:12:35 -0700 Subject: [PATCH 1/4] optimize dev build loop to cache the highlighted files This results in 11.3s edit-refresh time. --- .gitignore | 1 + packages/lit-dev-content/.eleventy.js | 1 + .../playground-plugin/blocking-renderer.ts | 73 ++++++++++++++++++- .../src/playground-plugin/plugin.ts | 4 +- 4 files changed, 76 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 9aec331f3..1e9ffad7c 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ test-results *.tsbuildinfo .wireit +.highlights_cache \ No newline at end of file diff --git a/packages/lit-dev-content/.eleventy.js b/packages/lit-dev-content/.eleventy.js index faa635e04..06758742c 100644 --- a/packages/lit-dev-content/.eleventy.js +++ b/packages/lit-dev-content/.eleventy.js @@ -56,6 +56,7 @@ module.exports = function (eleventyConfig) { eleventyConfig.addPlugin(eleventyNavigationPlugin); eleventyConfig.addPlugin(playgroundPlugin, { sandboxUrl: ENV.playgroundSandboxUrl, + isDevMode: DEV, }); if (!DEV) { // In dev mode, we symlink these directly to source. diff --git a/packages/lit-dev-tools-cjs/src/playground-plugin/blocking-renderer.ts b/packages/lit-dev-tools-cjs/src/playground-plugin/blocking-renderer.ts index 334a9e698..2a42b5ccd 100644 --- a/packages/lit-dev-tools-cjs/src/playground-plugin/blocking-renderer.ts +++ b/packages/lit-dev-tools-cjs/src/playground-plugin/blocking-renderer.ts @@ -6,6 +6,12 @@ import * as workerthreads from 'worker_threads'; import * as pathlib from 'path'; +import * as fs from 'fs'; + +const cachedHighlightsDir = pathlib.resolve( + __dirname, + '../../.highlights_cache/' +); export type WorkerMessage = HandshakeMessage | Render | Shutdown; @@ -32,6 +38,33 @@ export interface Shutdown { type: 'shutdown'; } +const highlightKey = (lang: string, code: string) => `[${lang}]:${code}`; + +// Create a cache key for the highlighted strings. This is a +// simple digest build from a DJB2-ish hash modified from: +// https://github.com/darkskyapp/string-hash/blob/master/index.js +// It is modified from @lit-labs/ssr-client. +// Goals: +// - Extremely low collision rate. We may not be able to detect collisions. +// - Extremely fast. +// - Extremely small code size. +// - Safe to include in HTML comment text or attribute value. +// - Easily specifiable and implementable in multiple languages. +// We don't care about cryptographic suitability. +export const digestToFileName = (stringToDigest: string) => { + // Number of 32 bit elements to use to create template digests + const digestSize = 5; + const hashes = new Uint32Array(digestSize).fill(5381); + for (let i = 0; i < stringToDigest.length; i++) { + hashes[i % digestSize] = + (hashes[i % digestSize] * 33) ^ stringToDigest.charCodeAt(i); + } + const str = String.fromCharCode(...new Uint8Array(hashes.buffer)); + return Buffer.from(str, 'binary') + .toString('base64') + .replace(/[<>:"'/\\|?*]/g, '_'); +}; + export class BlockingRenderer { /** Worker that performs rendering. */ private worker: workerthreads.Worker; @@ -44,8 +77,14 @@ export class BlockingRenderer { private decoder = new TextDecoder(); private exited = false; private renderTimeout: number; + private isDevMode = false; - constructor({renderTimeout = 60_000, maxHtmlBytes = 1024 * 1024} = {}) { + constructor({ + renderTimeout = 60_000, + maxHtmlBytes = 1024 * 1024, + isDevMode = false, + } = {}) { + this.isDevMode = isDevMode; this.renderTimeout = renderTimeout; this.sharedHtml = new Uint8Array(new SharedArrayBuffer(maxHtmlBytes)); this.worker = new workerthreads.Worker( @@ -70,6 +109,11 @@ export class BlockingRenderer { htmlBuffer: this.sharedHtml, notify: this.sharedNotify, }); + try { + fs.mkdirSync(cachedHighlightsDir); + } catch { + // Directory already exists. + } } async stop(): Promise { @@ -82,10 +126,36 @@ export class BlockingRenderer { }); } + private getCachedRender(lang: string, code: string): string | null { + if (!this.isDevMode) { + return null; + } + const fileName = digestToFileName(highlightKey(lang, code)); + const absoluteFilePath = pathlib.resolve(cachedHighlightsDir, fileName); + if (fs.existsSync(absoluteFilePath)) { + return fs.readFileSync(absoluteFilePath, {encoding: 'utf8'}); + } + return null; + } + + private writeCachedRender(lang: string, code: string, html: string) { + if (!this.isDevMode) { + // In production mode, don't write out cached files. + return; + } + const fileName = digestToFileName(highlightKey(lang, code)); + const absoluteFilePath = pathlib.resolve(cachedHighlightsDir, fileName); + fs.writeFileSync(absoluteFilePath, html); + } + render(lang: 'js' | 'ts' | 'html' | 'css', code: string): {html: string} { if (this.exited) { throw new Error('BlockingRenderer worker has already exited'); } + const cachedResult = this.getCachedRender(lang, code); + if (cachedResult !== null) { + return {html: cachedResult}; + } this.workerPost({type: 'render', lang, code}); if ( Atomics.wait(this.sharedNotify, 0, 0, this.renderTimeout) === 'timed-out' @@ -97,6 +167,7 @@ export class BlockingRenderer { const raw = this.decoder.decode(this.sharedHtml); const length = this.sharedLength[0]; const html = raw.substring(0, length); + this.writeCachedRender(lang, code, html); return {html}; } diff --git a/packages/lit-dev-tools-cjs/src/playground-plugin/plugin.ts b/packages/lit-dev-tools-cjs/src/playground-plugin/plugin.ts index 90648ec1f..e9fa8c5c9 100644 --- a/packages/lit-dev-tools-cjs/src/playground-plugin/plugin.ts +++ b/packages/lit-dev-tools-cjs/src/playground-plugin/plugin.ts @@ -89,12 +89,12 @@ const countVisibleLines = (filename: string, code: string): number => { */ export const playgroundPlugin = ( eleventyConfig: EleventyConfig, - {sandboxUrl}: {sandboxUrl: string} + {sandboxUrl, isDevMode}: {sandboxUrl: string; isDevMode: boolean} ) => { let renderer: BlockingRenderer | undefined; eleventyConfig.on('eleventy.before', () => { - renderer = new BlockingRenderer(); + renderer = new BlockingRenderer({isDevMode}); }); eleventyConfig.on('eleventy.after', async () => { From 691b88a768bb4034bd15796ca6ee91d96415562e Mon Sep 17 00:00:00 2001 From: Andrew Jakubowicz Date: Wed, 18 Oct 2023 16:23:48 -0700 Subject: [PATCH 2/4] tidy up code --- .../playground-plugin/blocking-renderer.ts | 54 +++++++++++++------ 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/packages/lit-dev-tools-cjs/src/playground-plugin/blocking-renderer.ts b/packages/lit-dev-tools-cjs/src/playground-plugin/blocking-renderer.ts index 2a42b5ccd..ad1edbbbf 100644 --- a/packages/lit-dev-tools-cjs/src/playground-plugin/blocking-renderer.ts +++ b/packages/lit-dev-tools-cjs/src/playground-plugin/blocking-renderer.ts @@ -38,12 +38,10 @@ export interface Shutdown { type: 'shutdown'; } -const highlightKey = (lang: string, code: string) => `[${lang}]:${code}`; - // Create a cache key for the highlighted strings. This is a // simple digest build from a DJB2-ish hash modified from: // https://github.com/darkskyapp/string-hash/blob/master/index.js -// It is modified from @lit-labs/ssr-client. +// This is modified from @lit-labs/ssr-client. // Goals: // - Extremely low collision rate. We may not be able to detect collisions. // - Extremely fast. @@ -51,7 +49,7 @@ const highlightKey = (lang: string, code: string) => `[${lang}]:${code}`; // - Safe to include in HTML comment text or attribute value. // - Easily specifiable and implementable in multiple languages. // We don't care about cryptographic suitability. -export const digestToFileName = (stringToDigest: string) => { +const digestToFileName = (stringToDigest: string) => { // Number of 32 bit elements to use to create template digests const digestSize = 5; const hashes = new Uint32Array(digestSize).fill(5381); @@ -60,11 +58,18 @@ export const digestToFileName = (stringToDigest: string) => { (hashes[i % digestSize] * 33) ^ stringToDigest.charCodeAt(i); } const str = String.fromCharCode(...new Uint8Array(hashes.buffer)); - return Buffer.from(str, 'binary') - .toString('base64') - .replace(/[<>:"'/\\|?*]/g, '_'); + return ( + Buffer.from(str, 'binary') + .toString('base64') + // These characters do not play well in file names. Replace with + // underscores. + .replace(/[<>:"'/\\|?*]/g, '_') + ); }; +const createUniqueFileNameKey = (lang: string, code: string) => + digestToFileName(`[${lang}]:${code}`); + export class BlockingRenderer { /** Worker that performs rendering. */ private worker: workerthreads.Worker; @@ -77,6 +82,13 @@ export class BlockingRenderer { private decoder = new TextDecoder(); private exited = false; private renderTimeout: number; + + /** + * Spawning a headless browser to syntax highlight code is expensive and slows + * down the edit/refresh loop during development. When developing, cache the + * syntax highlighted DOM in the filesystem so it can be retrieved if + * previously seen. + */ private isDevMode = false; constructor({ @@ -130,7 +142,7 @@ export class BlockingRenderer { if (!this.isDevMode) { return null; } - const fileName = digestToFileName(highlightKey(lang, code)); + const fileName = createUniqueFileNameKey(lang, code); const absoluteFilePath = pathlib.resolve(cachedHighlightsDir, fileName); if (fs.existsSync(absoluteFilePath)) { return fs.readFileSync(absoluteFilePath, {encoding: 'utf8'}); @@ -139,23 +151,34 @@ export class BlockingRenderer { } private writeCachedRender(lang: string, code: string, html: string) { - if (!this.isDevMode) { - // In production mode, don't write out cached files. - return; - } - const fileName = digestToFileName(highlightKey(lang, code)); + const fileName = createUniqueFileNameKey(lang, code); const absoluteFilePath = pathlib.resolve(cachedHighlightsDir, fileName); fs.writeFileSync(absoluteFilePath, html); } render(lang: 'js' | 'ts' | 'html' | 'css', code: string): {html: string} { - if (this.exited) { - throw new Error('BlockingRenderer worker has already exited'); + if (!this.isDevMode) { + // In production, skip all caching. + return this.renderWithWorker(lang, code); } + // In dev mode, speed up the edit-refresh loop by caching the syntax + // highlighted code. const cachedResult = this.getCachedRender(lang, code); if (cachedResult !== null) { return {html: cachedResult}; } + const {html} = this.renderWithWorker(lang, code); + this.writeCachedRender(lang, code, html); + return {html}; + } + + private renderWithWorker( + lang: 'js' | 'ts' | 'html' | 'css', + code: string + ): {html: string} { + if (this.exited) { + throw new Error('BlockingRenderer worker has already exited'); + } this.workerPost({type: 'render', lang, code}); if ( Atomics.wait(this.sharedNotify, 0, 0, this.renderTimeout) === 'timed-out' @@ -167,7 +190,6 @@ export class BlockingRenderer { const raw = this.decoder.decode(this.sharedHtml); const length = this.sharedLength[0]; const html = raw.substring(0, length); - this.writeCachedRender(lang, code, html); return {html}; } From c83234bfb046c9729d5aa5fcbace86fca4673379 Mon Sep 17 00:00:00 2001 From: Andrew Jakubowicz Date: Wed, 18 Oct 2023 17:02:27 -0700 Subject: [PATCH 3/4] code review feedback --- .../playground-plugin/blocking-renderer.ts | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/lit-dev-tools-cjs/src/playground-plugin/blocking-renderer.ts b/packages/lit-dev-tools-cjs/src/playground-plugin/blocking-renderer.ts index ad1edbbbf..b1f6a229b 100644 --- a/packages/lit-dev-tools-cjs/src/playground-plugin/blocking-renderer.ts +++ b/packages/lit-dev-tools-cjs/src/playground-plugin/blocking-renderer.ts @@ -138,21 +138,22 @@ export class BlockingRenderer { }); } - private getCachedRender(lang: string, code: string): string | null { - if (!this.isDevMode) { - return null; - } - const fileName = createUniqueFileNameKey(lang, code); - const absoluteFilePath = pathlib.resolve(cachedHighlightsDir, fileName); + private getCachedRender(cachedFileName: string): string | null { + const absoluteFilePath = pathlib.resolve( + cachedHighlightsDir, + cachedFileName + ); if (fs.existsSync(absoluteFilePath)) { return fs.readFileSync(absoluteFilePath, {encoding: 'utf8'}); } return null; } - private writeCachedRender(lang: string, code: string, html: string) { - const fileName = createUniqueFileNameKey(lang, code); - const absoluteFilePath = pathlib.resolve(cachedHighlightsDir, fileName); + private writeCachedRender(cachedFileName: string, html: string) { + const absoluteFilePath = pathlib.resolve( + cachedHighlightsDir, + cachedFileName + ); fs.writeFileSync(absoluteFilePath, html); } @@ -163,12 +164,13 @@ export class BlockingRenderer { } // In dev mode, speed up the edit-refresh loop by caching the syntax // highlighted code. - const cachedResult = this.getCachedRender(lang, code); + const cachedFileName = createUniqueFileNameKey(lang, code); + const cachedResult = this.getCachedRender(cachedFileName); if (cachedResult !== null) { return {html: cachedResult}; } const {html} = this.renderWithWorker(lang, code); - this.writeCachedRender(lang, code, html); + this.writeCachedRender(cachedFileName, html); return {html}; } From 4eb6adf07bbf78b3047a929066c8638016eda65f Mon Sep 17 00:00:00 2001 From: Andrew Jakubowicz Date: Wed, 18 Oct 2023 17:03:32 -0700 Subject: [PATCH 4/4] Update packages/lit-dev-tools-cjs/src/playground-plugin/blocking-renderer.ts Co-authored-by: Augustine Kim --- .../src/playground-plugin/blocking-renderer.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/lit-dev-tools-cjs/src/playground-plugin/blocking-renderer.ts b/packages/lit-dev-tools-cjs/src/playground-plugin/blocking-renderer.ts index b1f6a229b..3225ef97f 100644 --- a/packages/lit-dev-tools-cjs/src/playground-plugin/blocking-renderer.ts +++ b/packages/lit-dev-tools-cjs/src/playground-plugin/blocking-renderer.ts @@ -123,8 +123,12 @@ export class BlockingRenderer { }); try { fs.mkdirSync(cachedHighlightsDir); - } catch { - // Directory already exists. + } catch (error) { + if ((error as {code: string}).code === 'EEXIST') { + // Directory already exists. + } else { + throw error; + } } }