Public: anyone can read this; the crew can change it.
A repository's tree and blob pages name their phases in Server-Timing: session, git, highlight, render #3224
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3194-a-repository-s-tree-and-blob-pages-name → main 2 commits fork
3 files
changed +185
−18
docs/CACHING.md +15
| @@ -230,6 +230,21 @@ and rounds to 0.0): | ||
| 230 | 230 | the table below (a push to the boards repository is one of them). |
| 231 | 231 | - **Max age:** 30 seconds. |
| 232 | 232 | |
| 233 | ### Where a code page's time goes (#3194) | |
| 234 | ||
| 235 | A repository's tree and blob pages (`/:owner/:name/tree/…`, `/blob/…`) name | |
| 236 | each phase once, after `app`; the phases never add up to more than `app`. | |
| 237 | ||
| 238 | - `session`: resolving the viewer, as on `/map`. | |
| 239 | - `git`: every object read of the request summed into one entry: the tree | |
| 240 | or blob, the ref set and the explorer's listing. | |
| 241 | - `highlight`: blob only; `highlightBlob`, the syntax colouring. A rendered | |
| 242 | Markdown file and a binary or oversized file send none. | |
| 243 | - `render`: building the page, less highlighting. On a tree it includes the | |
| 244 | README read, which happens while the page is built. On a blob it is the | |
| 245 | pane plus the page; the `?fragment=1` preview sends the pane alone, and | |
| 246 | the explorer fragment of a tree sends no `render`. | |
| 247 | ||
| 233 | 248 | ### Where home's time goes (#3160) |
| 234 | 249 | |
| 235 | 250 | `/`'s `Server-Timing` names `feed`, `kpis` and `panels` for a signed-in |
src/web/pages/repo.ts +73 −18
| @@ -46,6 +46,7 @@ import { | ||
| 46 | 46 | } from "../chrome.ts"; |
| 47 | 47 | import { codeJs } from "../client.ts"; |
| 48 | 48 | import { highlightBlob } from "../code.ts"; |
| 49 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; | |
| 49 | 50 | import { liveClientJs, liveMount } from "../live.ts"; |
| 50 | 51 | import { appStrip } from "./apps.ts"; |
| 51 | 52 | import { studioApp } from "../studio/app.ts"; |
| @@ -437,6 +438,7 @@ export function blobBody( | ||
| 437 | 438 | ref: string, |
| 438 | 439 | path: string, |
| 439 | 440 | blob: Blob, |
| 441 | highlighted?: (ms: number) => void, | |
| 440 | 442 | ): { content: Html; lines: number } { |
| 441 | 443 | const encoded = encodeURIComponent(ref); |
| 442 | 444 | if (blob.kind === "binary") { |
| @@ -471,11 +473,13 @@ export function blobBody( | ||
| 471 | 473 | const text = new TextDecoder().decode(blob.body).replace(/\n$/, ""); |
| 472 | 474 | // Highlight the whole file (multi-line constructs need full context), |
| 473 | 475 | // then take the first N lines for rendering. |
| 474 | const highlighted = highlightBlob(text, path); | |
| 475 | const lines = highlighted.slice(0, MAX_RENDER_LINES); | |
| 476 | const truncated = highlighted.length > MAX_RENDER_LINES; | |
| 476 | const began = performance.now(); | |
| 477 | const highlight = highlightBlob(text, path); | |
| 478 | highlighted?.(performance.now() - began); | |
| 479 | const lines = highlight.slice(0, MAX_RENDER_LINES); | |
| 480 | const truncated = highlight.length > MAX_RENDER_LINES; | |
| 477 | 481 | return { |
| 478 | lines: highlighted.length, | |
| 482 | lines: highlight.length, | |
| 479 | 483 | content: html`<div class="card code"> |
| 480 | 484 | <table> |
| 481 | 485 | ${lines.map( |
| @@ -491,7 +495,7 @@ export function blobBody( | ||
| 491 | 495 | empty({ |
| 492 | 496 | icon: "alert", |
| 493 | 497 | title: "Showing the first 5,000 lines", |
| 494 | note: `This file has ${highlighted.length.toLocaleString()} lines. Fetch the raw bytes for all of them.`, | |
| 498 | note: `This file has ${highlight.length.toLocaleString()} lines. Fetch the raw bytes for all of them.`, | |
| 495 | 499 | action: btn("View raw", { href: `${base}/raw/${encoded}/${path}` }), |
| 496 | 500 | }), |
| 497 | 501 | )}`, |
| @@ -810,6 +814,8 @@ export function renderBlob( | ||
| 810 | 814 | path: string; |
| 811 | 815 | blob: Blob; |
| 812 | 816 | view?: BlobView; |
| 817 | /** Told how long highlighting took, for Server-Timing. */ | |
| 818 | highlighted?: (ms: number) => void; | |
| 813 | 819 | }, |
| 814 | 820 | ): Html { |
| 815 | 821 | const base = scope.base; |
| @@ -819,7 +825,7 @@ export function renderBlob( | ||
| 819 | 825 | const { content, lines } = |
| 820 | 826 | view === "rendered" && o.blob.kind === "text" |
| 821 | 827 | ? renderedMarkdown(o.blob) |
| 822 | : blobBody(base, o.ref, o.path, o.blob); | |
| 828 | : blobBody(base, o.ref, o.path, o.blob, o.highlighted); | |
| 823 | 829 | const sizeText = |
| 824 | 830 | o.blob.kind === "too_large" ? "" : sizeWithTokens(o.blob.size, o.path); |
| 825 | 831 | const fileHref = `${base}/blob/${encoded}/${o.path}`; |
| @@ -1110,6 +1116,28 @@ export function codePage( | ||
| 1110 | 1116 | }); |
| 1111 | 1117 | } |
| 1112 | 1118 | |
| 1119 | /** | |
| 1120 | * The object reads of one request, summed: Server-Timing names `git` once, | |
| 1121 | * however many reads the page made. | |
| 1122 | */ | |
| 1123 | function gitClock(request: Request): { | |
| 1124 | read: <T>(work: () => Promise<T>) => Promise<T>; | |
| 1125 | done: () => void; | |
| 1126 | } { | |
| 1127 | let total = 0; | |
| 1128 | return { | |
| 1129 | read: async (work) => { | |
| 1130 | const began = performance.now(); | |
| 1131 | try { | |
| 1132 | return await work(); | |
| 1133 | } finally { | |
| 1134 | total += performance.now() - began; | |
| 1135 | } | |
| 1136 | }, | |
| 1137 | done: () => notePhase(request, "git", total), | |
| 1138 | }; | |
| 1139 | } | |
| 1140 | ||
| 1113 | 1141 | // GET /:owner/:name/tree/:ref[/*path] |
| 1114 | 1142 | export async function treePage(ctx: Ctx): Promise<Response> { |
| 1115 | 1143 | const repo = loadRepo(ctx); |
| @@ -1117,20 +1145,29 @@ export async function treePage(ctx: Ctx): Promise<Response> { | ||
| 1117 | 1145 | const scope = localScope(ctx, repo); |
| 1118 | 1146 | const ref = ctx.params.ref ?? ""; |
| 1119 | 1147 | const path = ctx.params.path ?? ""; |
| 1120 | const entries = await scope.source.tree(ref, path); | |
| 1148 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 1149 | const git = gitClock(ctx.request); | |
| 1150 | const entries = await git.read(() => scope.source.tree(ref, path)); | |
| 1121 | 1151 | if (!entries.ok) return notFoundPage(ctx); |
| 1122 | 1152 | |
| 1123 | 1153 | // The explorer's lazy expansion asks for exactly one directory. |
| 1124 | if (ctx.url.searchParams.get("fragment") === "explorer") | |
| 1154 | if (ctx.url.searchParams.get("fragment") === "explorer") { | |
| 1155 | git.done(); | |
| 1125 | 1156 | return fragment(explorerFragment(scope.base, ref, path, entries.value), { |
| 1126 | 1157 | request: ctx.request, |
| 1127 | 1158 | }); |
| 1159 | } | |
| 1128 | 1160 | |
| 1129 | const [refs, explorer] = await Promise.all([ | |
| 1130 | refSetFrom(scope.source), | |
| 1131 | loadExplorer(scope.source, ref, path, false), | |
| 1132 | ]); | |
| 1133 | return codePage(ctx, scope, { | |
| 1161 | const [refs, explorer] = await git.read(() => | |
| 1162 | Promise.all([ | |
| 1163 | refSetFrom(scope.source), | |
| 1164 | loadExplorer(scope.source, ref, path, false), | |
| 1165 | ]), | |
| 1166 | ); | |
| 1167 | git.done(); | |
| 1168 | // `render` includes the README read: it is fetched while the page is built. | |
| 1169 | const began = performance.now(); | |
| 1170 | const response = codePage(ctx, scope, { | |
| 1134 | 1171 | title: `${path || ref} · ${repo.name}`, |
| 1135 | 1172 | ref, |
| 1136 | 1173 | explorer: explorerColumn(scope, explorer, { |
| @@ -1144,6 +1181,8 @@ export async function treePage(ctx: Ctx): Promise<Response> { | ||
| 1144 | 1181 | entries: entries.value, |
| 1145 | 1182 | }), |
| 1146 | 1183 | }); |
| 1184 | notePhase(ctx.request, "render", performance.now() - began); | |
| 1185 | return response; | |
| 1147 | 1186 | } |
| 1148 | 1187 | |
| 1149 | 1188 | // GET /:owner/:name/blob/:ref/*path |
| @@ -1153,25 +1192,39 @@ export async function blobPage(ctx: Ctx): Promise<Response> { | ||
| 1153 | 1192 | const scope = localScope(ctx, repo); |
| 1154 | 1193 | const ref = ctx.params.ref ?? ""; |
| 1155 | 1194 | const path = ctx.params.path ?? ""; |
| 1156 | const blob = await scope.source.blob(ref, path); | |
| 1195 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 1196 | const git = gitClock(ctx.request); | |
| 1197 | const blob = await git.read(() => scope.source.blob(ref, path)); | |
| 1157 | 1198 | if (!blob.ok) return notFoundPage(ctx); |
| 1158 | const refs = await refSetFrom(scope.source); | |
| 1199 | const refs = await git.read(() => refSetFrom(scope.source)); | |
| 1200 | let highlightMs: number | undefined; | |
| 1201 | const renderStart = performance.now(); | |
| 1159 | 1202 | const pane = renderBlob(scope, { |
| 1160 | 1203 | refs, |
| 1161 | 1204 | ref, |
| 1162 | 1205 | path, |
| 1163 | 1206 | blob: capped(blob.value), |
| 1164 | 1207 | view: blobView(ctx.url.searchParams.get("view")), |
| 1208 | highlighted: (ms) => (highlightMs = (highlightMs ?? 0) + ms), | |
| 1165 | 1209 | }); |
| 1210 | if (highlightMs !== undefined) notePhase(ctx.request, "highlight", highlightMs); | |
| 1211 | const paneMs = performance.now() - renderStart - (highlightMs ?? 0); | |
| 1166 | 1212 | |
| 1167 | 1213 | // The preview a script drops into the pane beside the explorer. Same |
| 1168 | 1214 | // renderer, same markup — a preview and a real page load are the same file. |
| 1169 | if (ctx.url.searchParams.get("fragment") === "1") | |
| 1215 | if (ctx.url.searchParams.get("fragment") === "1") { | |
| 1216 | git.done(); | |
| 1217 | notePhase(ctx.request, "render", paneMs); | |
| 1170 | 1218 | return fragment(pane, { request: ctx.request }); |
| 1219 | } | |
| 1171 | 1220 | |
| 1172 | const explorer = await loadExplorer(scope.source, ref, path, true); | |
| 1221 | const explorer = await git.read(() => | |
| 1222 | loadExplorer(scope.source, ref, path, true), | |
| 1223 | ); | |
| 1224 | git.done(); | |
| 1173 | 1225 | const filename = path.split("/").pop() ?? path; |
| 1174 | return codePage(ctx, scope, { | |
| 1226 | const pageStart = performance.now(); | |
| 1227 | const response = await codePage(ctx, scope, { | |
| 1175 | 1228 | title: `${filename} · ${repo.name}`, |
| 1176 | 1229 | ref, |
| 1177 | 1230 | explorer: explorerColumn(scope, explorer, { |
| @@ -1184,6 +1237,8 @@ export async function blobPage(ctx: Ctx): Promise<Response> { | ||
| 1184 | 1237 | }), |
| 1185 | 1238 | content: pane, |
| 1186 | 1239 | }); |
| 1240 | notePhase(ctx.request, "render", paneMs + performance.now() - pageStart); | |
| 1241 | return response; | |
| 1187 | 1242 | } |
| 1188 | 1243 | |
| 1189 | 1244 | /** |
test/browse-timing.test.ts added +97
| @@ -0,0 +1,97 @@ | ||
| 1 | /** A repository's tree and blob pages name their phases in Server-Timing. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 8 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 9 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 10 | ||
| 11 | const env = gitClientEnv({ | |
| 12 | GIT_AUTHOR_NAME: "A", | |
| 13 | GIT_AUTHOR_EMAIL: "[email protected]", | |
| 14 | GIT_COMMITTER_NAME: "A", | |
| 15 | GIT_COMMITTER_EMAIL: "[email protected]", | |
| 16 | }); | |
| 17 | ||
| 18 | async function git(args: string[], cwd: string): Promise<void> { | |
| 19 | const p = Bun.spawn(["git", ...args], { cwd, env, stdout: "ignore", stderr: "pipe" }); | |
| 20 | const [err, code] = await Promise.all([new Response(p.stderr).text(), p.exited]); | |
| 21 | if (code !== 0) throw new Error(`git ${args.join(" ")}: ${err}`); | |
| 22 | } | |
| 23 | ||
| 24 | let dataDir = "", work = ""; | |
| 25 | let app: App, servers: Servers, bearer = ""; | |
| 26 | ||
| 27 | beforeAll(async () => { | |
| 28 | dataDir = await mkdtemp(join(tmpdir(), "forge-btime-")); | |
| 29 | work = await mkdtemp(join(tmpdir(), "forge-btimework-")); | |
| 30 | app = createApp({ dataDir, port: 0 }); | |
| 31 | servers = await serve(app); | |
| 32 | const base = servers.url; | |
| 33 | await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" }); | |
| 34 | const minted = await fetch(`${base}/api/v1/tokens`, { | |
| 35 | method: "POST", | |
| 36 | headers: { "content-type": "application/json" }, | |
| 37 | body: JSON.stringify({ adminConfirm: true, username: "alice", password: "correct-horse-battery", name: "cli", scope: "admin" }), | |
| 38 | }); | |
| 39 | const { token } = (await minted.json()) as { token: string }; | |
| 40 | bearer = token; | |
| 41 | await fetch(`${base}/api/v1/repos`, { | |
| 42 | method: "POST", | |
| 43 | headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, | |
| 44 | body: JSON.stringify({ name: "app", description: "x" }), | |
| 45 | }); | |
| 46 | await git(["clone", `${base.replace("http://", `http://alice:${token}@`)}/alice/app.git`, "app"], work); | |
| 47 | const dir = join(work, "app"); | |
| 48 | await Bun.write(join(dir, "a.ts"), "export const a = 1;\n"); | |
| 49 | await Bun.write(join(dir, "README.md"), "# app\n"); | |
| 50 | await git(["add", "."], dir); | |
| 51 | await git(["commit", "-m", "one"], dir); | |
| 52 | await git(["push", "origin", "main"], dir); | |
| 53 | }, 60_000); | |
| 54 | ||
| 55 | afterAll(async () => { | |
| 56 | servers.stop(); | |
| 57 | app.close(); | |
| 58 | await rm(dataDir, { recursive: true, force: true }); | |
| 59 | await rm(work, { recursive: true, force: true }); | |
| 60 | }); | |
| 61 | ||
| 62 | const names = async (path: string): Promise<string[]> => { | |
| 63 | const res = await fetch(`${servers.url}${path}`, { headers: { authorization: `Bearer ${bearer}` } }); | |
| 64 | await res.arrayBuffer(); | |
| 65 | expect(res.status).toBe(200); | |
| 66 | return (res.headers.get("server-timing") ?? "").split(", ").map((p) => p.split(";")[0]!); | |
| 67 | }; | |
| 68 | ||
| 69 | const once = (got: string[]): void => { | |
| 70 | expect(new Set(got).size).toBe(got.length); | |
| 71 | }; | |
| 72 | ||
| 73 | test("a tree names session, git and render once each, and no highlight", async () => { | |
| 74 | const got = await names("/alice/app/tree/main"); | |
| 75 | expect(got).toEqual(expect.arrayContaining(["app", "session", "git", "render"])); | |
| 76 | expect(got).not.toContain("highlight"); | |
| 77 | once(got); | |
| 78 | }); | |
| 79 | ||
| 80 | test("a blob names session, git, highlight and render once each", async () => { | |
| 81 | const got = await names("/alice/app/blob/main/a.ts"); | |
| 82 | expect(got).toEqual(expect.arrayContaining(["app", "session", "git", "highlight", "render"])); | |
| 83 | once(got); | |
| 84 | }); | |
| 85 | ||
| 86 | test("a blob preview fragment names render and git once, no explorer read", async () => { | |
| 87 | const got = await names("/alice/app/blob/main/a.ts?fragment=1"); | |
| 88 | expect(got).toEqual(expect.arrayContaining(["session", "git", "highlight", "render"])); | |
| 89 | once(got); | |
| 90 | }); | |
| 91 | ||
| 92 | test("a rendered Markdown blob sends no highlight", async () => { | |
| 93 | const got = await names("/alice/app/blob/main/README.md"); | |
| 94 | expect(got).toEqual(expect.arrayContaining(["git", "render"])); | |
| 95 | expect(got).not.toContain("highlight"); | |
| 96 | once(got); | |
| 97 | }); |
new above
offline