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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/3194-a-repository-s-tree-and-blob-pages-name → main 2 commits fork

  • Merged. scout/3194-a-repository-s-tree-and-blob-pages-name was merged into main as c4c6b00.

Review

Sign in to review this change.
3 files changed +185 −18
docs/CACHING.md +15
@@ -230,6 +230,21 @@ and rounds to 0.0):
230230 the table below (a push to the boards repository is one of them).
231231 - **Max age:** 30 seconds.
232232
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
233248 ### Where home's time goes (#3160)
234249
235250 `/`'s `Server-Timing` names `feed`, `kpis` and `panels` for a signed-in
src/web/pages/repo.ts +73 −18
@@ -46,6 +46,7 @@ import {
4646 } from "../chrome.ts";
4747 import { codeJs } from "../client.ts";
4848 import { highlightBlob } from "../code.ts";
49 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
4950 import { liveClientJs, liveMount } from "../live.ts";
5051 import { appStrip } from "./apps.ts";
5152 import { studioApp } from "../studio/app.ts";
@@ -437,6 +438,7 @@ export function blobBody(
437438 ref: string,
438439 path: string,
439440 blob: Blob,
441 highlighted?: (ms: number) => void,
440442 ): { content: Html; lines: number } {
441443 const encoded = encodeURIComponent(ref);
442444 if (blob.kind === "binary") {
@@ -471,11 +473,13 @@ export function blobBody(
471473 const text = new TextDecoder().decode(blob.body).replace(/\n$/, "");
472474 // Highlight the whole file (multi-line constructs need full context),
473475 // 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;
477481 return {
478 lines: highlighted.length,
482 lines: highlight.length,
479483 content: html`<div class="card code">
480484 <table>
481485 ${lines.map(
@@ -491,7 +495,7 @@ export function blobBody(
491495 empty({
492496 icon: "alert",
493497 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.`,
495499 action: btn("View raw", { href: `${base}/raw/${encoded}/${path}` }),
496500 }),
497501 )}`,
@@ -810,6 +814,8 @@ export function renderBlob(
810814 path: string;
811815 blob: Blob;
812816 view?: BlobView;
817 /** Told how long highlighting took, for Server-Timing. */
818 highlighted?: (ms: number) => void;
813819 },
814820 ): Html {
815821 const base = scope.base;
@@ -819,7 +825,7 @@ export function renderBlob(
819825 const { content, lines } =
820826 view === "rendered" && o.blob.kind === "text"
821827 ? renderedMarkdown(o.blob)
822 : blobBody(base, o.ref, o.path, o.blob);
828 : blobBody(base, o.ref, o.path, o.blob, o.highlighted);
823829 const sizeText =
824830 o.blob.kind === "too_large" ? "" : sizeWithTokens(o.blob.size, o.path);
825831 const fileHref = `${base}/blob/${encoded}/${o.path}`;
@@ -1110,6 +1116,28 @@ export function codePage(
11101116 });
11111117 }
11121118
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
11131141 // GET /:owner/:name/tree/:ref[/*path]
11141142 export async function treePage(ctx: Ctx): Promise<Response> {
11151143 const repo = loadRepo(ctx);
@@ -1117,20 +1145,29 @@ export async function treePage(ctx: Ctx): Promise<Response> {
11171145 const scope = localScope(ctx, repo);
11181146 const ref = ctx.params.ref ?? "";
11191147 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));
11211151 if (!entries.ok) return notFoundPage(ctx);
11221152
11231153 // 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();
11251156 return fragment(explorerFragment(scope.base, ref, path, entries.value), {
11261157 request: ctx.request,
11271158 });
1159 }
11281160
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, {
11341171 title: `${path || ref} · ${repo.name}`,
11351172 ref,
11361173 explorer: explorerColumn(scope, explorer, {
@@ -1144,6 +1181,8 @@ export async function treePage(ctx: Ctx): Promise<Response> {
11441181 entries: entries.value,
11451182 }),
11461183 });
1184 notePhase(ctx.request, "render", performance.now() - began);
1185 return response;
11471186 }
11481187
11491188 // GET /:owner/:name/blob/:ref/*path
@@ -1153,25 +1192,39 @@ export async function blobPage(ctx: Ctx): Promise<Response> {
11531192 const scope = localScope(ctx, repo);
11541193 const ref = ctx.params.ref ?? "";
11551194 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));
11571198 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();
11591202 const pane = renderBlob(scope, {
11601203 refs,
11611204 ref,
11621205 path,
11631206 blob: capped(blob.value),
11641207 view: blobView(ctx.url.searchParams.get("view")),
1208 highlighted: (ms) => (highlightMs = (highlightMs ?? 0) + ms),
11651209 });
1210 if (highlightMs !== undefined) notePhase(ctx.request, "highlight", highlightMs);
1211 const paneMs = performance.now() - renderStart - (highlightMs ?? 0);
11661212
11671213 // The preview a script drops into the pane beside the explorer. Same
11681214 // 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);
11701218 return fragment(pane, { request: ctx.request });
1219 }
11711220
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();
11731225 const filename = path.split("/").pop() ?? path;
1174 return codePage(ctx, scope, {
1226 const pageStart = performance.now();
1227 const response = await codePage(ctx, scope, {
11751228 title: `${filename} · ${repo.name}`,
11761229 ref,
11771230 explorer: explorerColumn(scope, explorer, {
@@ -1184,6 +1237,8 @@ export async function blobPage(ctx: Ctx): Promise<Response> {
11841237 }),
11851238 content: pane,
11861239 });
1240 notePhase(ctx.request, "render", paneMs + performance.now() - pageStart);
1241 return response;
11871242 }
11881243
11891244 /**
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs