A file is highlighted once per data centre: highlighted lines are kept by a hash of their language and text, in the isolate and the data centre's cache
Highlighting was most of the site's CPU on a file's page: 63% of a 130-line TypeScript file's (about 30 ms of CPU a request) and 85% of a 1,500-line TSX file's (about 250 ms), measured on the built site with fake services and node --cpu-prof. Crawlers read public files all day, and every read highlighted the text again. lib/content-cache.ts keeps work done on content that never changes: the isolate's memory first (a least-recently-used map bounded by characters), then the data centre's cache under content.g1t.internal, then the work, whose answer goes to both (the shared write in waitUntil). The key is a SHA-256 of the name, a version and the content's parts, length-prefixed; nothing about the viewer, the repository, the path or the branch is in it, so the same text reached any way is one entry, and an entry can only be found by someone who already has the text. A null answer (no language, too large, failed) is never kept; a shared entry that cannot be read is treated as missing. lib/highlight.server.ts keeps a blob's highlighted lines that way (highlight-lines, about 8 MB per isolate) and a pull request's first highlighted files (highlight-diff, by each line's side and text, lib/diff.ts diffContent; line numbers and the path are not in it). Bump HIGHLIGHT_VERSION when the theme, grammars, Shiki or linesToHtml change what they make. Shiki's module is asked for once per isolate, not on every highlight. The unused highlight() (codeToHtml) is gone. Tests: content keys, the LRU's bounds and order, compute once then isolate then data centre, versions never read each other, null and broken entries; a highlighted diff split into its HTML and given back is the same highlighted file.
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { CONTENT_ORIGIN, type SharedCache, WeightedLru, contentCache, contentKey, weightOfLines } from "./content-cache.ts"; | |
| 5 | + | ||
| 6 | + | test("a content key is a hash of every part, in order, and never of a join that could collide", async () => { | |
| 7 | + | const key = await contentKey(["typescript", "const a = 1;"]); | |
| 8 | + | assert.match(key, /^[0-9a-f]{64}$/); | |
| 9 | + | assert.equal(key, await contentKey(["typescript", "const a = 1;"])); | |
| 10 | + | assert.notEqual(key, await contentKey(["tsx", "const a = 1;"])); | |
| 11 | + | assert.notEqual(key, await contentKey(["typescript", "const a = 2;"])); | |
| 12 | + | // The same characters split differently are different content. | |
| 13 | + | assert.notEqual(await contentKey(["ab", "c"]), await contentKey(["a", "bc"])); | |
| 14 | + | assert.notEqual(await contentKey(["a:b"]), await contentKey(["a", "b"])); | |
| 15 | + | }); | |
| 16 | + | ||
| 17 | + | test("the isolate's memory keeps the most recently used within its weight", () => { | |
| 18 | + | const lru = new WeightedLru<string>(100, (value) => value.length); | |
| 19 | + | lru.set("a", "x".repeat(20)); | |
| 20 | + | lru.set("b", "x".repeat(20)); | |
| 21 | + | lru.set("c", "x".repeat(20)); | |
| 22 | + | assert.equal(lru.weight, 60); | |
| 23 | + | // Reading `a` makes it the newest; `b` is now the oldest. | |
| 24 | + | assert.ok(lru.get("a")); | |
| 25 | + | lru.set("d", "x".repeat(20)); | |
| 26 | + | lru.set("e", "x".repeat(20)); | |
| 27 | + | lru.set("f", "x".repeat(20)); | |
| 28 | + | assert.equal(lru.get("b"), undefined); | |
| 29 | + | assert.ok(lru.get("a")); | |
| 30 | + | assert.ok(lru.weight <= 100); | |
| 31 | + | // Replacing an entry does not count it twice. | |
| 32 | + | lru.set("a", "y"); | |
| 33 | + | assert.equal(lru.get("a"), "y"); | |
| 34 | + | assert.ok(lru.weight <= 100); | |
| 35 | + | // One entry heavier than a quarter of the whole is never kept. | |
| 36 | + | lru.set("big", "x".repeat(26)); | |
| 37 | + | assert.equal(lru.get("big"), undefined); | |
| 38 | + | }); | |
| 39 | + | ||
| 40 | + | test("lines weigh their characters", () => { | |
| 41 | + | assert.equal(weightOfLines(["ab", null, ""]), 2 + 8 + 0 + 8 + 0 + 8); | |
| 42 | + | }); | |
| 43 | + | ||
| 44 | + | /** A data centre's cache in memory, as caches.default answers. */ | |
| 45 | + | function fakeShared() { | |
| 46 | + | const kept = new Map<string, string>(); | |
| 47 | + | const shared: SharedCache = { | |
| 48 | + | async match(url) { | |
| 49 | + | const body = kept.get(url); | |
| 50 | + | return body === undefined ? undefined : new Response(body); | |
| 51 | + | }, | |
| 52 | + | async put(url, response) { | |
| 53 | + | kept.set(url, await response.text()); | |
| 54 | + | }, | |
| 55 | + | }; | |
| 56 | + | return { kept, shared }; | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | test("an answer is computed once, then read from the isolate, then from the data centre", async () => { | |
| 60 | + | const { kept, shared } = fakeShared(); | |
| 61 | + | const deferred: Promise<unknown>[] = []; | |
| 62 | + | const options = { | |
| 63 | + | name: "test-lines", | |
| 64 | + | version: "v1", | |
| 65 | + | maxWeight: 1_000, | |
| 66 | + | weigh: weightOfLines, | |
| 67 | + | shared: () => shared, | |
| 68 | + | defer: (work: Promise<unknown>) => void deferred.push(work), | |
| 69 | + | }; | |
| 70 | + | const cached = contentCache<string[]>(options); | |
| 71 | + | let runs = 0; | |
| 72 | + | const compute = async () => { | |
| 73 | + | runs += 1; | |
| 74 | + | return ["<b>a</b>", "b"]; | |
| 75 | + | }; | |
| 76 | + | assert.deepEqual(await cached(["rust", "a\nb"], compute), ["<b>a</b>", "b"]); | |
| 77 | + | await Promise.all(deferred); | |
| 78 | + | assert.equal(runs, 1); | |
| 79 | + | assert.equal(kept.size, 1); | |
| 80 | + | const [url] = kept.keys(); | |
| 81 | + | assert.ok(url!.startsWith(`${CONTENT_ORIGIN}test-lines/v1/`)); | |
| 82 | + | // The isolate has it. | |
| 83 | + | assert.deepEqual(await cached(["rust", "a\nb"], compute), ["<b>a</b>", "b"]); | |
| 84 | + | assert.equal(runs, 1); | |
| 85 | + | // Another isolate (a fresh cache) finds the data centre's copy. | |
| 86 | + | const other = contentCache<string[]>(options); | |
| 87 | + | assert.deepEqual(await other(["rust", "a\nb"], compute), ["<b>a</b>", "b"]); | |
| 88 | + | assert.equal(runs, 1); | |
| 89 | + | // Other content, or the same content for another language, is another entry. | |
| 90 | + | await cached(["rust", "a\nc"], compute); | |
| 91 | + | await cached(["go", "a\nb"], compute); | |
| 92 | + | assert.equal(runs, 3); | |
| 93 | + | }); | |
| 94 | + | ||
| 95 | + | test("a new version never reads what an old one kept", async () => { | |
| 96 | + | const { shared } = fakeShared(); | |
| 97 | + | const make = (version: string) => | |
| 98 | + | contentCache<string[]>({ name: "test", version, maxWeight: 1_000, weigh: weightOfLines, shared: () => shared, defer: () => {} }); | |
| 99 | + | const deferred: Promise<unknown>[] = []; | |
| 100 | + | const first = contentCache<string[]>({ | |
| 101 | + | name: "test", | |
| 102 | + | version: "v1", | |
| 103 | + | maxWeight: 1_000, | |
| 104 | + | weigh: weightOfLines, | |
| 105 | + | shared: () => shared, | |
| 106 | + | defer: (work) => void deferred.push(work), | |
| 107 | + | }); | |
| 108 | + | await first(["x"], async () => ["old"]); | |
| 109 | + | await Promise.all(deferred); | |
| 110 | + | assert.deepEqual(await make("v1")(["x"], async () => ["new"]), ["old"]); | |
| 111 | + | assert.deepEqual(await make("v2")(["x"], async () => ["new"]), ["new"]); | |
| 112 | + | }); | |
| 113 | + | ||
| 114 | + | test("nothing to keep is not kept, and a broken shared entry counts as missing", async () => { | |
| 115 | + | const { kept, shared } = fakeShared(); | |
| 116 | + | const cached = contentCache<string[]>({ name: "t", version: "v1", maxWeight: 1_000, weigh: weightOfLines, shared: () => shared, defer: () => {} }); | |
| 117 | + | let runs = 0; | |
| 118 | + | assert.equal(await cached(["none"], async () => (runs++, null)), null); | |
| 119 | + | assert.equal(await cached(["none"], async () => (runs++, null)), null); | |
| 120 | + | assert.equal(runs, 2); | |
| 121 | + | assert.equal(kept.size, 0); | |
| 122 | + | ||
| 123 | + | const url = `${CONTENT_ORIGIN}t/v1/${await contentKey(["t", "v1", "broken"])}`; | |
| 124 | + | kept.set(url, "{not json"); | |
| 125 | + | assert.deepEqual(await cached(["broken"], async () => ["fresh"]), ["fresh"]); | |
| 126 | + | }); | |
| 127 | + | ||
| 128 | + | test("without a data centre's cache the isolate still keeps answers", async () => { | |
| 129 | + | const cached = contentCache<string[]>({ name: "t", version: "v1", maxWeight: 1_000, weigh: weightOfLines, shared: () => null, defer: () => {} }); | |
| 130 | + | let runs = 0; | |
| 131 | + | await cached(["a"], async () => (runs++, ["x"])); | |
| 132 | + | await cached(["a"], async () => (runs++, ["x"])); | |
| 133 | + | assert.equal(runs, 1); | |
| 134 | + | }); |
| 1 | + | /** | |
| 2 | + | * Work done on content that never changes once written: highlighting a | |
| 3 | + | * file's text, turning a README into a tree. The answer depends on the | |
| 4 | + | * content alone, so it is kept by a hash of that content and is good for | |
| 5 | + | * as long as the code that made it is the same (`version`, part of every | |
| 6 | + | * key). No viewer, path or branch is in the key, so the same text reached | |
| 7 | + | * by another branch, commit or page is the same entry, and nothing in an | |
| 8 | + | * entry says whose it is or where it came from. | |
| 9 | + | */ | |
| 10 | + | ||
| 11 | + | /** | |
| 12 | + | * A key for `parts`: SHA-256 over each part, length-prefixed, so | |
| 13 | + | * `["ab", "c"]` and `["a", "bc"]` never collide. Hex, 64 characters. | |
| 14 | + | */ | |
| 15 | + | export async function contentKey(parts: readonly string[]): Promise<string> { | |
| 16 | + | const text = parts.map((part) => `${part.length}:${part}`).join(""); | |
| 17 | + | const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)); | |
| 18 | + | return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | /** | |
| 22 | + | * An isolate's memory of recent answers, bounded by their total weight | |
| 23 | + | * (characters, roughly) rather than by count, so a few large files cannot | |
| 24 | + | * hold more than `maxWeight` between them. The least recently used go | |
| 25 | + | * first. An entry heavier than a quarter of the whole is never kept. | |
| 26 | + | */ | |
| 27 | + | export class WeightedLru<V> { | |
| 28 | + | private readonly entries = new Map<string, { value: V; weight: number }>(); | |
| 29 | + | private total = 0; | |
| 30 | + | private readonly maxWeight: number; | |
| 31 | + | private readonly weigh: (value: V) => number; | |
| 32 | + | ||
| 33 | + | constructor(maxWeight: number, weigh: (value: V) => number) { | |
| 34 | + | this.maxWeight = maxWeight; | |
| 35 | + | this.weigh = weigh; | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | get(key: string): V | undefined { | |
| 39 | + | const entry = this.entries.get(key); | |
| 40 | + | if (!entry) return undefined; | |
| 41 | + | // Most recently used last. | |
| 42 | + | this.entries.delete(key); | |
| 43 | + | this.entries.set(key, entry); | |
| 44 | + | return entry.value; | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | set(key: string, value: V): void { | |
| 48 | + | const weight = Math.max(1, this.weigh(value)); | |
| 49 | + | this.delete(key); | |
| 50 | + | if (weight > this.maxWeight / 4) return; | |
| 51 | + | this.entries.set(key, { value, weight }); | |
| 52 | + | this.total += weight; | |
| 53 | + | for (const [oldest, entry] of this.entries) { | |
| 54 | + | if (this.total <= this.maxWeight) break; | |
| 55 | + | this.entries.delete(oldest); | |
| 56 | + | this.total -= entry.weight; | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | delete(key: string): void { | |
| 61 | + | const entry = this.entries.get(key); | |
| 62 | + | if (!entry) return; | |
| 63 | + | this.entries.delete(key); | |
| 64 | + | this.total -= entry.weight; | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | get size(): number { | |
| 68 | + | return this.entries.size; | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | get weight(): number { | |
| 72 | + | return this.total; | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /** The weight of a list of strings: their characters. */ | |
| 77 | + | export function weightOfLines(lines: readonly (string | null)[]): number { | |
| 78 | + | let total = 0; | |
| 79 | + | for (const line of lines) total += (line?.length ?? 0) + 8; | |
| 80 | + | return total; | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | /** The data centre's cache, as far as a content cache needs it (`caches.default`). */ | |
| 84 | + | export type SharedCache = { | |
| 85 | + | match(url: string): Promise<Response | undefined>; | |
| 86 | + | put(url: string, response: Response): Promise<void>; | |
| 87 | + | }; | |
| 88 | + | ||
| 89 | + | /** Where shared entries live: a name nothing outside can ask for. */ | |
| 90 | + | export const CONTENT_ORIGIN = "https://content.g1t.internal/"; | |
| 91 | + | ||
| 92 | + | /** A shared entry is good for this long; its key changes when its content or code does. */ | |
| 93 | + | export const SHARED_MAX_AGE_SECONDS = 30 * 24 * 60 * 60; | |
| 94 | + | ||
| 95 | + | export type ContentCacheOptions<V> = { | |
| 96 | + | /** What is kept, e.g. `highlight-lines`: part of every key. */ | |
| 97 | + | name: string; | |
| 98 | + | /** Bumped whenever the code that makes an entry changes what it makes. */ | |
| 99 | + | version: string; | |
| 100 | + | /** The isolate's share, in `weigh` units. */ | |
| 101 | + | maxWeight: number; | |
| 102 | + | weigh: (value: V) => number; | |
| 103 | + | /** The data centre's cache, or null where there is none (tests, local development). */ | |
| 104 | + | shared: () => SharedCache | null; | |
| 105 | + | /** Lets a write to the shared cache finish after the answer (`waitUntil`). */ | |
| 106 | + | defer: (work: Promise<unknown>) => void; | |
| 107 | + | }; | |
| 108 | + | ||
| 109 | + | /** | |
| 110 | + | * `compute`'s answer for content `parts`, kept in the isolate and in the | |
| 111 | + | * data centre's cache. The isolate is asked first, then the data centre; | |
| 112 | + | * only when neither has it is `compute` run, and its answer kept in both. | |
| 113 | + | * A null answer (nothing to keep: no language, too large, failed) is | |
| 114 | + | * returned and never kept. A shared entry that cannot be read is treated | |
| 115 | + | * as missing. | |
| 116 | + | */ | |
| 117 | + | export function contentCache<V>(options: ContentCacheOptions<V>) { | |
| 118 | + | const memory = new WeightedLru<V>(options.maxWeight, options.weigh); | |
| 119 | + | const cached = async (parts: readonly string[], compute: () => Promise<V | null>): Promise<V | null> => { | |
| 120 | + | const key = await contentKey([options.name, options.version, ...parts]); | |
| 121 | + | const remembered = memory.get(key); | |
| 122 | + | if (remembered !== undefined) return remembered; | |
| 123 | + | const url = `${CONTENT_ORIGIN}${options.name}/${options.version}/${key}`; | |
| 124 | + | const shared = options.shared(); | |
| 125 | + | if (shared) { | |
| 126 | + | const found = await shared.match(url).catch(() => undefined); | |
| 127 | + | if (found) { | |
| 128 | + | const value = (await found.json().catch(() => undefined)) as V | undefined; | |
| 129 | + | if (value !== undefined && value !== null) { | |
| 130 | + | memory.set(key, value); | |
| 131 | + | return value; | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | const value = await compute(); | |
| 136 | + | if (value === null) return null; | |
| 137 | + | memory.set(key, value); | |
| 138 | + | if (shared) { | |
| 139 | + | const response = new Response(JSON.stringify(value), { | |
| 140 | + | headers: { "content-type": "application/json", "cache-control": `public, max-age=${SHARED_MAX_AGE_SECONDS}` }, | |
| 141 | + | }); | |
| 142 | + | options.defer(shared.put(url, response).catch(() => undefined)); | |
| 143 | + | } | |
| 144 | + | return value; | |
| 145 | + | }; | |
| 146 | + | return Object.assign(cached, { memory }); | |
| 147 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import type { FileDiff } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import { diffContent, htmlOfFile, withHtml } from "./diff.ts"; | |
| 7 | + | import { highlightFile } from "./shiki.ts"; | |
| 8 | + | ||
| 9 | + | const FILE: FileDiff = { | |
| 10 | + | path: "src/lib.rs", | |
| 11 | + | status: "modified", | |
| 12 | + | additions: 2, | |
| 13 | + | deletions: 1, | |
| 14 | + | binary: false, | |
| 15 | + | hunks: [ | |
| 16 | + | { | |
| 17 | + | lines: [ | |
| 18 | + | { kind: "context", old: 1, new: 1, text: "fn main() {" }, | |
| 19 | + | { kind: "delete", old: 2, new: null, text: ' println!("old");' }, | |
| 20 | + | { kind: "add", old: null, new: 2, text: ' println!("new");' }, | |
| 21 | + | { kind: "add", old: null, new: 3, text: "" }, | |
| 22 | + | { kind: "context", old: 3, new: 4, text: "}" }, | |
| 23 | + | ], | |
| 24 | + | }, | |
| 25 | + | { lines: [{ kind: "add", old: null, new: 10, text: "// end" }] }, | |
| 26 | + | ], | |
| 27 | + | }; | |
| 28 | + | ||
| 29 | + | test("a highlighted file's HTML, kept apart and given back, is the same highlighted file", async () => { | |
| 30 | + | const highlighted = await highlightFile(FILE); | |
| 31 | + | assert.ok(highlighted); | |
| 32 | + | const html = htmlOfFile(highlighted); | |
| 33 | + | assert.equal(html.length, 2); | |
| 34 | + | assert.ok(html[0]!.every((row) => row !== null)); | |
| 35 | + | // Through the data centre's cache it travels as JSON. | |
| 36 | + | const kept = JSON.parse(JSON.stringify(html)); | |
| 37 | + | assert.deepEqual(withHtml(FILE, kept), highlighted); | |
| 38 | + | }); | |
| 39 | + | ||
| 40 | + | test("lines without HTML stay without it", () => { | |
| 41 | + | const html = [[null, "<b>x</b>", null, "", null], [null]]; | |
| 42 | + | const file = withHtml(FILE, html); | |
| 43 | + | assert.equal("html" in file.hunks[0]!.lines[0]!, false); | |
| 44 | + | assert.equal(file.hunks[0]!.lines[1]!.html, "<b>x</b>"); | |
| 45 | + | // An empty line's HTML is kept: it is not the same as none. | |
| 46 | + | assert.equal(file.hunks[0]!.lines[3]!.html, ""); | |
| 47 | + | assert.equal("html" in file.hunks[1]!.lines[0]!, false); | |
| 48 | + | // The diff given in is not changed. | |
| 49 | + | assert.equal("html" in FILE.hunks[0]!.lines[1]!, false); | |
| 50 | + | }); | |
| 51 | + | ||
| 52 | + | test("what a file's colours depend on: each line's side and text, not its numbers or path", () => { | |
| 53 | + | const moved: FileDiff = { | |
| 54 | + | ...FILE, | |
| 55 | + | path: "other/lib.rs", | |
| 56 | + | hunks: FILE.hunks.map((hunk) => ({ lines: hunk.lines.map((line) => ({ ...line, old: 99, new: 99 })) })), | |
| 57 | + | }; | |
| 58 | + | assert.equal(diffContent(moved), diffContent(FILE)); | |
| 59 | + | const sideChanged: FileDiff = { | |
| 60 | + | ...FILE, | |
| 61 | + | hunks: [{ lines: FILE.hunks[0]!.lines.map((line, n) => (n === 0 ? { ...line, kind: "add" as const } : line)) }, FILE.hunks[1]!], | |
| 62 | + | }; | |
| 63 | + | assert.notEqual(diffContent(sideChanged), diffContent(FILE)); | |
| 64 | + | // A line split differently is different content. | |
| 65 | + | const one: FileDiff = { ...FILE, hunks: [{ lines: [{ kind: "add", old: null, new: 1, text: "a\nb" }] }] }; | |
| 66 | + | const two: FileDiff = { | |
| 67 | + | ...FILE, | |
| 68 | + | hunks: [{ lines: [{ kind: "add", old: null, new: 1, text: "a" }, { kind: "add", old: null, new: 2, text: "b" }] }], | |
| 69 | + | }; | |
| 70 | + | assert.notEqual(diffContent(one), diffContent(two)); | |
| 71 | + | }); |
| 4 | 4 | export type HighlightedLine = DiffLine & { html?: string }; | |
| 5 | 5 | export type HighlightedFile = Omit<FileDiff, "hunks"> & { hunks: { lines: HighlightedLine[] }[] }; | |
| 6 | 6 | export type HighlightedComparison = Omit<Comparison, "files"> & { files: HighlightedFile[] }; | |
| 7 | + | ||
| 8 | + | /** Each hunk's lines' HTML, null for a line without any: what a highlighted file adds to its diff. */ | |
| 9 | + | export type FileHtml = (string | null)[][]; | |
| 10 | + | ||
| 11 | + | /** The HTML `highlighted` gives its lines, to keep apart from the diff it came from. */ | |
| 12 | + | export function htmlOfFile(highlighted: HighlightedFile): FileHtml { | |
| 13 | + | return highlighted.hunks.map((hunk) => hunk.lines.map((line) => line.html ?? null)); | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | /** `file` with `html` given back to its lines: the same as the highlighted file it was taken from. */ | |
| 17 | + | export function withHtml(file: FileDiff, html: FileHtml): HighlightedFile { | |
| 18 | + | return { | |
| 19 | + | ...file, | |
| 20 | + | hunks: file.hunks.map((hunk, h) => ({ | |
| 21 | + | lines: hunk.lines.map((line, n): HighlightedLine => { | |
| 22 | + | const row = html[h]?.[n]; | |
| 23 | + | return row == null ? { ...line } : { ...line, html: row }; | |
| 24 | + | }), | |
| 25 | + | })), | |
| 26 | + | }; | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /** | |
| 30 | + | * What a file's highlighting depends on besides its language: each line's | |
| 31 | + | * side and text, hunk by hunk. Line numbers and the path do not change the | |
| 32 | + | * colours, so they are left out. | |
| 33 | + | */ | |
| 34 | + | export function diffContent(file: FileDiff): string { | |
| 35 | + | return JSON.stringify(file.hunks.map((hunk) => hunk.lines.map((line) => [line.kind, line.text]))); | |
| 36 | + | } |
| 1 | + | import { waitUntil } from "cloudflare:workers"; | |
| 2 | + | ||
| 1 | 3 | import type { Comparison } from "@g1t/contracts"; | |
| 2 | 4 | ||
| 3 | − | import type { HighlightedComparison, HighlightedFile } from "./diff"; | |
| 5 | + | import { type SharedCache, contentCache, weightOfLines } from "./content-cache"; | |
| 6 | + | import { type FileHtml, type HighlightedComparison, type HighlightedFile, diffContent, htmlOfFile, withHtml } from "./diff"; | |
| 4 | 7 | ||
| 5 | 8 | // Shiki and its grammars load on the first file highlighted, not when the | |
| 6 | − | // Worker starts: most requests highlight nothing. | |
| 7 | − | const shiki = () => import("./shiki"); | |
| 9 | + | // Worker starts: most requests highlight nothing. The module is asked for | |
| 10 | + | // once per isolate. | |
| 11 | + | let shikiModule: Promise<typeof import("./shiki")> | undefined; | |
| 12 | + | const shiki = () => (shikiModule ??= import("./shiki")); | |
| 8 | 13 | ||
| 9 | 14 | const MAX_HIGHLIGHT_CHARS = 200_000; | |
| 10 | 15 | ||
| 11 | 16 | /** | |
| 12 | − | * Highlighted HTML for a file, or null when its language is unknown or it | |
| 13 | − | * is too large, in which case the caller shows plain text. | |
| 17 | + | * Highlighting is most of the work of a file's page (about two thirds of a | |
| 18 | + | * small file's, nine tenths of a large one's), and the same text always | |
| 19 | + | * highlights the same way. So it is kept by a hash of the text and its | |
| 20 | + | * language: in the isolate, and in the data centre's cache for the other | |
| 21 | + | * isolates there (lib/content-cache.ts). A file read again on another | |
| 22 | + | * branch or commit, through blame, or by the next crawler, is highlighted | |
| 23 | + | * once. Bump `HIGHLIGHT_VERSION` when the theme, the grammars, Shiki or | |
| 24 | + | * `linesToHtml` change what they make. | |
| 14 | 25 | */ | |
| 15 | − | export async function highlight(path: string, text: string): Promise<string | null> { | |
| 16 | − | const { THEME, getHighlighter, languageOf } = await shiki(); | |
| 17 | − | const lang = languageOf(path); | |
| 18 | − | if (!lang || text.length > MAX_HIGHLIGHT_CHARS) return null; | |
| 26 | + | export const HIGHLIGHT_VERSION = "v1"; | |
| 27 | + | ||
| 28 | + | function dataCentre(): SharedCache | null { | |
| 19 | 29 | try { | |
| 20 | − | return (await getHighlighter()).codeToHtml(text.replace(/\n$/, ""), { lang, theme: THEME }); | |
| 30 | + | return (caches as unknown as { default: Cache }).default ?? null; | |
| 21 | 31 | } catch { | |
| 32 | + | // No cache (local development). | |
| 22 | 33 | return null; | |
| 23 | 34 | } | |
| 24 | 35 | } | |
| 25 | 36 | ||
| 37 | + | function defer(work: Promise<unknown>) { | |
| 38 | + | try { | |
| 39 | + | waitUntil(work); | |
| 40 | + | } catch { | |
| 41 | + | // Outside a request: the write finishes or not; nothing waits on it. | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 26 | 45 | /** | |
| 46 | + | * About 8 MB of highlighted lines per isolate (two bytes a character); one | |
| 47 | + | * file up to a quarter of that, which a 1,500-line file fits. A pull | |
| 48 | + | * request's first screens are at most 40,000 characters of text, about | |
| 49 | + | * ten times that highlighted. | |
| 50 | + | */ | |
| 51 | + | const linesCache = contentCache<string[]>({ | |
| 52 | + | name: "highlight-lines", | |
| 53 | + | version: HIGHLIGHT_VERSION, | |
| 54 | + | maxWeight: 4_000_000, | |
| 55 | + | weigh: weightOfLines, | |
| 56 | + | shared: dataCentre, | |
| 57 | + | defer, | |
| 58 | + | }); | |
| 59 | + | ||
| 60 | + | const filesCache = contentCache<FileHtml>({ | |
| 61 | + | name: "highlight-diff", | |
| 62 | + | version: HIGHLIGHT_VERSION, | |
| 63 | + | maxWeight: 2_000_000, | |
| 64 | + | weigh: (html) => html.reduce((sum, hunk) => sum + weightOfLines(hunk), 0), | |
| 65 | + | shared: dataCentre, | |
| 66 | + | defer, | |
| 67 | + | }); | |
| 68 | + | ||
| 69 | + | /** | |
| 27 | 70 | * A file's lines as highlighted HTML, one string per line, or null when its | |
| 28 | 71 | * language is unknown or it is too large. | |
| 29 | 72 | */ | |
| 30 | 73 | export async function highlightLines(path: string, text: string): Promise<string[] | null> { | |
| 31 | − | const { getHighlighter, languageOf, linesToHtml } = await shiki(); | |
| 74 | + | const { languageOf } = await shiki(); | |
| 32 | 75 | const lang = languageOf(path); | |
| 33 | 76 | if (!lang || text.length > MAX_HIGHLIGHT_CHARS) return null; | |
| 34 | − | try { | |
| 35 | − | return linesToHtml(await getHighlighter(), text.replace(/\n$/, ""), lang); | |
| 36 | − | } catch { | |
| 37 | − | return null; | |
| 38 | − | } | |
| 77 | + | const code = text.replace(/\n$/, ""); | |
| 78 | + | return linesCache([lang, code], async () => { | |
| 79 | + | const { getHighlighter, linesToHtml } = await shiki(); | |
| 80 | + | try { | |
| 81 | + | return linesToHtml(await getHighlighter(), code, lang); | |
| 82 | + | } catch { | |
| 83 | + | return null; | |
| 84 | + | } | |
| 85 | + | }); | |
| 39 | 86 | } | |
| 40 | 87 | ||
| 41 | 88 | /** | |
| ⋯ | |||
| 45 | 92 | */ | |
| 46 | 93 | const FIRST_SCREENS_CHARS = 40_000; | |
| 47 | 94 | ||
| 95 | + | /** A file's diff highlighted, from the cache when the same lines were highlighted before. */ | |
| 96 | + | async function highlightFileCached(file: Comparison["files"][number]): Promise<HighlightedFile | null> { | |
| 97 | + | const { highlightFile, languageOf } = await shiki(); | |
| 98 | + | const lang = languageOf(file.path); | |
| 99 | + | // Nothing to highlight: highlightFile says so without any work. | |
| 100 | + | if (!lang || file.binary) return null; | |
| 101 | + | const html = await filesCache([lang, diffContent(file)], async () => { | |
| 102 | + | const highlighted = await highlightFile(file).catch(() => null); | |
| 103 | + | return highlighted ? htmlOfFile(highlighted) : null; | |
| 104 | + | }); | |
| 105 | + | return html ? withHtml(file, html) : null; | |
| 106 | + | } | |
| 107 | + | ||
| 48 | 108 | /** | |
| 49 | 109 | * A comparison with its first files' lines highlighted, in order, until | |
| 50 | 110 | * the budget runs out, so what is on screen when the page arrives already | |
| ⋯ | |||
| 62 | 122 | continue; | |
| 63 | 123 | } | |
| 64 | 124 | budget -= size; | |
| 65 | − | const { highlightFile } = await shiki(); | |
| 66 | − | files.push((await highlightFile(file).catch(() => null)) ?? file); | |
| 125 | + | files.push((await highlightFileCached(file).catch(() => null)) ?? file); | |
| 67 | 126 | } | |
| 68 | 127 | return { ...comparison, files }; | |
| 69 | 128 | } | |