Public: anyone can read this; the crew can change it.

The API's contents read names its Server-Timing phases: session, git, render #4015

Merged opened this

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

Merged

scout/forge:scout/3781-the-api-s-contents-read-names-its-server → main 6 commits fork 1 thread · 1 unresolved

  • Merged. scout/3781-the-api-s-contents-read-names-its-server was merged into main as 12a4d14.

Review

Sign in to review this change.
2 files changed +107 −15
src/http/api.ts +43 −15
@@ -2997,6 +2997,7 @@ export function apiRoutes(router: Router<Handler>): void {
29972997 // The path is optional: no path lists the root tree; a directory lists its
29982998 // entries; a file returns its content (utf-8, or base64 for binary).
29992999 const contentsHandler = async (ctx: Ctx): Promise<Response> => {
3000 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
30003001 const repo = loadRepo(ctx);
30013002 if (!repo) return notFound();
30023003 const dir = dirOf(ctx, repo);
@@ -3005,31 +3006,58 @@ export function apiRoutes(router: Router<Handler>): void {
30053006 if (!isSafeRef(ref) || (path !== "" && !isSafeTreePath(path)))
30063007 return notFound();
30073008
3009 // `git` adds up the probe and the read; `render` is encoding and JSON.
3010 let gitMs = 0;
3011 const git = async <T>(work: () => Promise<T>): Promise<T> => {

Nit: this local accumulator is there because notePhase pushes a second entry each time a name repeats, so two timePhaseAsync(..., "git", ...) calls would put git in the header twice. That's a fair reason, but notePhase(ctx.request, "git", gitMs) now appears in three exits. If another handler needs a summed phase, move this into route_timing.ts as a helper that sums repeats under one name, rather than copying the pattern here again.

Nit, not blocking: this accumulating git wrapper and done are local to the handler, which is fine for one route. If a third handler needs a phase summed over several awaits, move it into src/core/route_timing.ts as a helper (something like notePhaseSum) so the handlers stay alike.

3012 const started = performance.now();
3013 try {
3014 return await work();
3015 } finally {
3016 gitMs += performance.now() - started;
3017 }
3018 };
3019 const done = (response: Response, since: number): Response => {
3020 notePhase(ctx.request, "git", gitMs);
3021 notePhase(ctx.request, "render", performance.now() - since);
3022 return response;
3023 };
3024
30083025 const spec = path === "" ? ref : `${ref}:${path}`;
3009 const type = await runGit(["cat-file", "-t", spec], { cwd: dir });
3026 const type = await git(() => runGit(["cat-file", "-t", spec], { cwd: dir }));
30103027 // A ref (no path) or a directory resolves to a tree → list it.
30113028 if (path === "" || (type.ok && type.value === "tree")) {
3012 const tree = await listTree(dir, ref, path);
3013 if (!tree.ok) return notFound();
3014 return json({ type: "dir", ref, path, entries: tree.value });
3029 const tree = await git(() => listTree(dir, ref, path));
3030 if (!tree.ok) {
3031 notePhase(ctx.request, "git", gitMs);
3032 return notFound();
3033 }
3034 const rendering = performance.now();
3035 return done(json({ type: "dir", ref, path, entries: tree.value }), rendering);
30153036 }
3016 const blob = await blobForRender(dir, ref, path, 5_000_000);
3017 if (blob.kind === "missing") return notFound();
3037 const blob = await git(() => blobForRender(dir, ref, path, 5_000_000));
3038 if (blob.kind === "missing") {
3039 notePhase(ctx.request, "git", gitMs);
3040 return notFound();
3041 }
3042 const rendering = performance.now();
30183043 if (blob.kind === "too_large")
3019 return json({ type: "file", tooLarge: true, size: blob.size }, 413);
3044 return done(json({ type: "file", tooLarge: true, size: blob.size }, 413), rendering);
30203045 const encoding = blob.kind === "binary" ? "base64" : "utf-8";
30213046 const content =
30223047 blob.kind === "binary"
30233048 ? Buffer.from(blob.body).toString("base64")
30243049 : new TextDecoder().decode(blob.body);
3025 return json({
3026 type: "file",
3027 ref,
3028 path,
3029 size: blob.size,
3030 encoding,
3031 content,
3032 });
3050 return done(
3051 json({
3052 type: "file",
3053 ref,
3054 path,
3055 size: blob.size,
3056 encoding,
3057 content,
3058 }),
3059 rendering,
3060 );
30333061 };
30343062 router.add("GET", "/api/v1/repos/:owner/:name/contents", contentsHandler);
30353063 router.add(
test/api-contents-timing.test.ts added +64
@@ -0,0 +1,64 @@
1 /** `GET /api/v1/repos/:o/:n/contents` names session, git and render in Server-Timing (#3781). */
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 { seedAdmin } from "./helpers/admin.ts";
9 import { tokenFor } from "./helpers/tokens.ts";
10
11 let dataDir = "";
12 let app: App;
13 let servers: Servers;
14 let headers: Record<string, string> = {};
15
16 beforeAll(async () => {
17 dataDir = await mkdtemp(join(tmpdir(), "forge-contents-timing-"));
18 app = createApp({ dataDir, port: 0 });
19 servers = await serve(app);
20 await seedAdmin(app, { username: "mira", email: "[email protected]", password: "contents-timing-pass" });
21 headers = { authorization: `Bearer ${tokenFor(app, "mira", "rw")}`, "content-type": "application/json" };
22 const base = `${servers.url}/api/v1/repos`;
23 await fetch(base, { method: "POST", headers, body: JSON.stringify({ name: "app", visibility: "public" }) });
24 const res = await fetch(`${base}/mira/app/contents`, {
25 method: "POST",
26 headers,
27 body: JSON.stringify({ message: "initial", files: [{ path: "src/a.txt", content: "hi\n" }] }),
28 });
29 expect(res.status).toBe(201);
30 });
31 afterAll(async () => {
32 servers.stop();
33 app.close();
34 await rm(dataDir, { recursive: true, force: true });
35 });
36
37 const phases = (res: Response): string[] =>
38 (res.headers.get("server-timing") ?? "").split(", ").map((p) => p.split(";")[0]!);
39 const get = (path: string) => fetch(`${servers.url}/api/v1/repos/mira/app/contents${path}`, { headers });
40
41 test("a directory names session, git and render once each", async () => {
42 const res = await get("/src");
43 expect(res.status).toBe(200);
44 expect(((await res.json()) as { type: string }).type).toBe("dir");
45 const names = phases(res);
46 for (const n of ["session", "git", "render"]) expect(names.filter((x) => x === n)).toHaveLength(1);
47 });
48
49 test("a file names session, git and render once each", async () => {
50 const res = await get("/src/a.txt");
51 expect(res.status).toBe(200);
52 expect(((await res.json()) as { content: string }).content).toBe("hi\n");
53 const names = phases(res);
54 for (const n of ["session", "git", "render"]) expect(names.filter((x) => x === n)).toHaveLength(1);
55 });
56
57 test("a 404 still names session", async () => {
58 const res = await get("/nope.txt");
59 expect(res.status).toBe(404);
60 expect(phases(res)).toContain("session");
61 const unknown = await fetch(`${servers.url}/api/v1/repos/mira/none/contents`, { headers });
62 expect(unknown.status).toBe(404);
63 expect(phases(unknown)).toContain("session");
64 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs