Public: anyone can read this; the crew can change it.
The API's contents read names its Server-Timing phases: session, git, render #4015
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3781-the-api-s-contents-read-names-its-server → main 6 commits fork 1 thread · 1 unresolved
2 files
changed +107
−15
src/http/api.ts +43 −15
| @@ -2997,6 +2997,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2997 | 2997 | // The path is optional: no path lists the root tree; a directory lists its |
| 2998 | 2998 | // entries; a file returns its content (utf-8, or base64 for binary). |
| 2999 | 2999 | const contentsHandler = async (ctx: Ctx): Promise<Response> => { |
| 3000 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 3000 | 3001 | const repo = loadRepo(ctx); |
| 3001 | 3002 | if (!repo) return notFound(); |
| 3002 | 3003 | const dir = dirOf(ctx, repo); |
| @@ -3005,31 +3006,58 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3005 | 3006 | if (!isSafeRef(ref) || (path !== "" && !isSafeTreePath(path))) |
| 3006 | 3007 | return notFound(); |
| 3007 | 3008 | |
| 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, not blocking: this accumulating |
||
| 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 | ||
| 3008 | 3025 | 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 })); | |
| 3010 | 3027 | // A ref (no path) or a directory resolves to a tree → list it. |
| 3011 | 3028 | 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); | |
| 3015 | 3036 | } |
| 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(); | |
| 3018 | 3043 | 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); | |
| 3020 | 3045 | const encoding = blob.kind === "binary" ? "base64" : "utf-8"; |
| 3021 | 3046 | const content = |
| 3022 | 3047 | blob.kind === "binary" |
| 3023 | 3048 | ? Buffer.from(blob.body).toString("base64") |
| 3024 | 3049 | : 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 | ); | |
| 3033 | 3061 | }; |
| 3034 | 3062 | router.add("GET", "/api/v1/repos/:owner/:name/contents", contentsHandler); |
| 3035 | 3063 | 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 | }); |
new above
offline
Nit: this local accumulator is there because
notePhasepushes a second entry each time a name repeats, so twotimePhaseAsync(..., "git", ...)calls would putgitin the header twice. That's a fair reason, butnotePhase(ctx.request, "git", gitMs)now appears in three exits. If another handler needs a summed phase, move this intoroute_timing.tsas a helper that sums repeats under one name, rather than copying the pattern here again.