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

An app's API reads name their Server-Timing phases: app, members, domains and env #4991

Merged opened this

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

Merged

scout/forge:scout/4981-an-app-s-api-reads-name-their-server-tim → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/4981-an-app-s-api-reads-name-their-server-tim was merged into main as 4307b2d.

Review

Sign in to review this change.
2 files changed +94 −11
src/http/api.ts +21 −11
@@ -5400,15 +5400,21 @@ export function apiRoutes(router: Router<Handler>): void {
54005400 });
54015401
54025402 router.add("GET", "/api/v1/repos/:owner/:name/app", (ctx) => {
5403 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
54035404 const repo = loadRepo(ctx);
54045405 if (!repo) return notFound();
5405 const view = appView(ctx.app, ctx.auth.user, repo);
5406 if (!view.ok) return notFound();
5407 const vitals = appVisitors(ctx.app, ctx.auth.user, repo);
5408 return json({
5409 ...appJson(ctx, view.value.app, repo, appsRuntime(ctx.app)),
5410 ...(vitals === undefined ? {} : { vitals }),
5406 const read = timePhase(ctx.request, "app", () => {

app here makes the header read app;dur=…, session;…, app;dur=…, render;…, and anything that looks up app by name gets the server total. The issue asked for this name and POST /repos already emits an app phase, so it is fine here. Worth one rename across all of them later.

5407 const view = appView(ctx.app, ctx.auth.user, repo);
5408 if (!view.ok) return null;
5409 return { view: view.value, vitals: appVisitors(ctx.app, ctx.auth.user, repo) };
54115410 });
5411 if (!read) return notFound();
5412 return timePhase(ctx.request, "render", () =>
5413 json({
5414 ...appJson(ctx, read.view.app, repo, appsRuntime(ctx.app)),
5415 ...(read.vitals === undefined ? {} : { vitals: read.vitals }),
5416 }),
5417 );
54125418 });
54135419
54145420 router.add("POST", "/api/v1/repos/:owner/:name/app", async (ctx) => {
@@ -5459,11 +5465,13 @@ export function apiRoutes(router: Router<Handler>): void {
54595465 // previews, nothing else. Read and changed by people who can write the
54605466 // repository, never an agent; a change is planned unless dryRun is false.
54615467 router.add("GET", "/api/v1/repos/:owner/:name/app/members", (ctx) => {
5468 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
54625469 const denied = requireScope(ctx, "ro");
54635470 if (denied) return denied;
54645471 const repo = loadRepo(ctx);
54655472 if (!repo) return notFound();
5466 return membersAnswer(readAppMembers(ctx.app, ctx.auth.user, repo));
5473 const read = timePhase(ctx.request, "members", () => readAppMembers(ctx.app, ctx.auth.user, repo));
5474 return timePhase(ctx.request, "render", () => membersAnswer(read));
54675475 });
54685476
54695477 router.add("PUT", "/api/v1/repos/:owner/:name/app/members", async (ctx) => {
@@ -5487,13 +5495,14 @@ export function apiRoutes(router: Router<Handler>): void {
54875495 // and admin over the repository, like the environment below; `:host` is
54885496 // one path segment, dots included.
54895497 router.add("GET", "/api/v1/repos/:owner/:name/app/domains", (ctx) => {
5498 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
54905499 const denied = requireScope(ctx, "admin");
54915500 if (denied) return denied;
54925501 const repo = loadRepo(ctx);
54935502 if (!repo) return notFound();
5494 const listed = listAppDomains(ctx.app, ctx.auth.user, repo);
5503 const listed = timePhase(ctx.request, "domains", () => listAppDomains(ctx.app, ctx.auth.user, repo));
54955504 if (!listed.ok) return respondAccess(listed.error, 422);
5496 return json({ items: listed.value, next_cursor: null });
5505 return timePhase(ctx.request, "render", () => json({ items: listed.value, next_cursor: null }));
54975506 });
54985507
54995508 router.add("POST", "/api/v1/repos/:owner/:name/app/domains", async (ctx) => {
@@ -5707,13 +5716,14 @@ export function apiRoutes(router: Router<Handler>): void {
57075716 // The app's environment: names in, names out, and never a value back.
57085717 // `:var`, not `:name` — `:name` is the repository's.
57095718 router.add("GET", "/api/v1/repos/:owner/:name/app/env", (ctx) => {
5719 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
57105720 const denied = requireScope(ctx, "admin");
57115721 if (denied) return denied;
57125722 const repo = loadRepo(ctx);
57135723 if (!repo) return notFound();
5714 const listed = listAppEnv(ctx.app, ctx.auth.user, repo);
5724 const listed = timePhase(ctx.request, "env", () => listAppEnv(ctx.app, ctx.auth.user, repo));
57155725 if (!listed.ok) return notFound();
5716 return json({ items: listed.value, next_cursor: null });
5726 return timePhase(ctx.request, "render", () => json({ items: listed.value, next_cursor: null }));
57175727 });
57185728
57195729 router.add("PUT", "/api/v1/repos/:owner/:name/app/env/:var", async (ctx) => {
test/app-read-timing.test.ts added +73
@@ -0,0 +1,73 @@
1 /** An app's API reads name their Server-Timing phases. */
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 { AppsRuntime } from "../src/apps/runtime.ts";
8 import { serve, type Servers } from "../src/http/server.ts";
9 import { seedAdmin } from "./helpers/admin.ts";
10 import { tokenFor } from "./helpers/tokens.ts";
11
12 let dataDir = "";
13 let app: App, servers: Servers, bearer = "";
14
15 const call = (path: string, init: RequestInit = {}, token = bearer) =>
16 fetch(`${servers.url}${path}`, {
17 ...init,
18 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
19 });
20
21 async function phases(path: string, token = bearer): Promise<{ status: number; names: string[] }> {
22 const res = await call(path, {}, token);
23 await res.arrayBuffer();
24 const all = (res.headers.get("server-timing") ?? "").split(", ").map((p) => p.split(";")[0]!);
25 // The server's own leading `app` (and `db`) entries are not route phases.
26 return { status: res.status, names: all.slice(all.indexOf("app") + 1) };
27 }
28
29 beforeAll(async () => {
30 dataDir = await mkdtemp(join(tmpdir(), "forge-apptime-"));
31 app = createApp({ dataDir, port: 0 });
32 servers = await serve(app);
33 const runtime = new AppsRuntime(app, {
34 domain: "apps.test",
35 scheme: "http",
36 publicPort: servers.port,
37 forgeUrl: servers.url,
38 });
39 await runtime.start();
40 await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" });
41 bearer = tokenFor(app, "alice", "admin");
42 const made = await call("/api/v1/repos", { method: "POST", body: JSON.stringify({ name: "hello" }) });
43 expect(made.status).toBe(201);
44 const on = await call("/api/v1/repos/alice/hello/app", { method: "POST", body: "{}" });
45 expect(on.status).toBe(201);
46 }, 30_000);
47
48 afterAll(async () => {
49 servers.stop();
50 app.close();
51 await rm(dataDir, { recursive: true, force: true });
52 });
53
54 const base = "/api/v1/repos/alice/hello/app";
55
56 for (const [route, read] of [
57 [base, "app"],
58 [`${base}/members`, "members"],
59 [`${base}/domains`, "domains"],
60 [`${base}/env`, "env"],
61 ] as const) {
62 test(`GET ${route} names session, ${read}, render in order`, async () => {
63 const got = await phases(route);
64 expect(got.status).toBe(200);
65 expect(got.names).toEqual(["session", read, "render"]);
66 });
67
68 test(`a refused GET ${route} still names session`, async () => {
69 const got = await phases(route.replace("/hello/", "/nothing/"));
70 expect(got.status).toBe(404);
71 expect(got.names).toEqual(["session"]);
72 });
73 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs