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

Optimize dev edit-refresh loop by caching the highlighted files #1229

Merged
merged 5 commits into from
Oct 19, 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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,4 @@ test-results
*.tsbuildinfo

.wireit
.highlights_cache
1 change: 1 addition & 0 deletions packages/lit-dev-content/.eleventy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
101 changes: 100 additions & 1 deletion packages/lit-dev-tools-cjs/src/playground-plugin/blocking-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -32,6 +38,38 @@ export interface Shutdown {
type: 'shutdown';
}

// 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
// This 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.
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')
// 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;
Expand All @@ -45,7 +83,20 @@ export class BlockingRenderer {
private exited = false;
private renderTimeout: number;

constructor({renderTimeout = 60_000, maxHtmlBytes = 1024 * 1024} = {}) {
/**
* 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({
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(
Expand All @@ -70,6 +121,15 @@ export class BlockingRenderer {
htmlBuffer: this.sharedHtml,
notify: this.sharedNotify,
});
try {
fs.mkdirSync(cachedHighlightsDir);
} catch (error) {
if ((error as {code: string}).code === 'EEXIST') {
// Directory already exists.
} else {
throw error;
}
}
}

async stop(): Promise<void> {
Expand All @@ -82,7 +142,46 @@ export class BlockingRenderer {
});
}

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(cachedFileName: string, html: string) {
const absoluteFilePath = pathlib.resolve(
cachedHighlightsDir,
cachedFileName
);
fs.writeFileSync(absoluteFilePath, html);
}

render(lang: 'js' | 'ts' | 'html' | 'css', code: string): {html: string} {
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 cachedFileName = createUniqueFileNameKey(lang, code);
const cachedResult = this.getCachedRender(cachedFileName);
if (cachedResult !== null) {
return {html: cachedResult};
}
const {html} = this.renderWithWorker(lang, code);
this.writeCachedRender(cachedFileName, 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');
}
Expand Down
4 changes: 2 additions & 2 deletions packages/lit-dev-tools-cjs/src/playground-plugin/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading