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

A child forge's check of its parent's releases is a 304 when nothing shipped #3762

Merged opened this

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

Merged

scout/forge:scout/3755-a-child-forge-s-check-of-its-parent-s-re → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/3755-a-child-forge-s-check-of-its-parent-s-re was merged into main as 913fa93.

Review

Sign in to review this change.
4 files changed +136 −16
src/core/parent_releases.ts +45 −9
@@ -110,6 +110,8 @@ interface Stored {
110110 triedAt: number;
111111 ok: boolean;
112112 releases: ParentRelease[];
113 /** The parent's ETag for `releases`, when it sent a well-formed one. */
114 etag?: string;
113115 }
114116
115117 function readStored(file: string, url: string): Stored | undefined {
@@ -122,13 +124,30 @@ function readStored(file: string, url: string): Stored | undefined {
122124 }
123125 }
124126
125 async function fetchDoc(url: string, f: typeof fetch): Promise<unknown> {
127 /** A strong ETag as the parent sends it; anything else is ignored. */
128 function strongEtag(raw: string | null | undefined): string | undefined {
129 return typeof raw === "string" && /^"[\x21\x23-\x7e]{1,128}"$/.test(raw)
130 ? raw
131 : undefined;
132 }
133
134 /** The parent's document and its ETag; `unchanged` when it answered 304. */
135 async function fetchDoc(
136 url: string,
137 f: typeof fetch,
138 etag?: string,
139 ): Promise<{ doc?: unknown; etag?: string; unchanged?: true }> {
126140 const res = await f(`${url.replace(/\/+$/, "")}/releases.json`, {
127141 signal: AbortSignal.timeout(TIMEOUT_MS),
128142 redirect: "error",
129 headers: { accept: "application/json" },
143 headers: {
144 accept: "application/json",
145 ...(etag === undefined ? {} : { "if-none-match": etag }),
146 },
130147 });
148 if (res.status === 304 && etag !== undefined) return { unchanged: true };
131149 if (!res.ok || res.body === null) throw new Error(`status ${res.status}`);
150 const tag = strongEtag(res.headers.get("etag"));
132151 const reader = res.body.getReader();
133152 const chunks: Uint8Array[] = [];
134153 let size = 0;
@@ -142,7 +161,10 @@ async function fetchDoc(url: string, f: typeof fetch): Promise<unknown> {
142161 }
143162 chunks.push(value);
144163 }
145 return JSON.parse(Buffer.concat(chunks).toString("utf8"));
164 return {
165 doc: JSON.parse(Buffer.concat(chunks).toString("utf8")),
166 ...(tag === undefined ? {} : { etag: tag }),
167 };
146168 }
147169
148170 const refreshing = new Map<string, Promise<void>>();
@@ -173,12 +195,23 @@ export function refreshParentReleases(
173195 const copy = kept?.fetchedAt ?? null;
174196 let next: Stored;
175197 try {
176 const releases = parseReleases(
177 await fetchDoc(url, opts.fetch ?? fetch),
178 opts.loopback ?? false,
179 );
180 if (releases === undefined) throw new Error("not a releases document");
181 next = { url, fetchedAt: now, triedAt: now, ok: true, releases };
198 // The ETag only counts beside a copy it describes.
199 const sent = copy === null ? undefined : strongEtag(kept?.etag);
200 const got = await fetchDoc(url, opts.fetch ?? fetch, sent);
201 if (got.unchanged === true && kept !== undefined) {
202 next = { ...kept, fetchedAt: now, triedAt: now, ok: true };
203 } else {
204 const releases = parseReleases(got.doc, opts.loopback ?? false);
205 if (releases === undefined) throw new Error("not a releases document");
206 next = {
207 url,
208 fetchedAt: now,
209 triedAt: now,
210 ok: true,
211 releases,
212 ...(got.etag === undefined ? {} : { etag: got.etag }),
213 };
214 }
182215 } catch {
183216 next = {
184217 url,
@@ -186,6 +219,9 @@ export function refreshParentReleases(
186219 triedAt: now,
187220 ok: false,
188221 releases: copy === null ? [] : (kept?.releases ?? []),
222 ...(copy === null || kept?.etag === undefined
223 ? {}
224 : { etag: kept.etag }),
189225 };
190226 }
191227 try {
src/web/standards.ts +28 −7
@@ -100,15 +100,36 @@ export function changePassword(): Response {
100100 * release to point at.
101101 */
102102 export function releasesJson(ctx: Ctx): Response {
103 const body = {
104 releases: releases.releases.map((r) => ({
105 name: r.version,
106 url: `${ctx.url.origin}/releases.json#${encodeURIComponent(r.version)}`,
107 })),
108 };
109 return text(JSON.stringify(body), "application/json");
103 const origin = ctx.url.origin;
104 let kept = releasesBodies.get(origin);
105 if (kept === undefined) {
106 const body = JSON.stringify({
107 releases: releases.releases.map((r) => ({
108 name: r.version,
109 url: `${origin}/releases.json#${encodeURIComponent(r.version)}`,
110 })),
111 });
112 const etag = `"${new Bun.CryptoHasher("sha256").update(body).digest("hex").slice(0, 32)}"`;
113 kept = { body, etag };
114 // Origins come from the Host header: keep a handful, not one per guess.
115 if (releasesBodies.size >= 16) releasesBodies.clear();
116 releasesBodies.set(origin, kept);
117 }
118 const cache = "public, max-age=3600";
119 const sent = ctx.request.headers.get("if-none-match");

Duplicates the cache-control string hard-coded in text(). Share one constant so the 200 and 304 cannot drift.

120 if (sent !== null && sent.split(",").some((t) => t.trim() === kept.etag))
121 return new Response(null, {
122 status: 304,
123 headers: { etag: kept.etag, "cache-control": cache },
124 });
125 const res = text(kept.body, "application/json");
126 res.headers.set("etag", kept.etag);
127 return res;
110128 }
111129
130 /** The releases document and its ETag, per origin; the file is read once at boot. */
131 const releasesBodies = new Map<string, { body: string; etag: string }>();
132
112133 /** GET /robots.txt */
113134 export function robotsTxt(ctx: Ctx): Response {
114135 return text(
test/parent-releases-idle.test.ts +43
@@ -38,3 +38,46 @@ test("refreshIdle resolves at once with nothing in flight, and waits for a slow
3838 await rm(dir, { recursive: true, force: true });
3939 }
4040 });
41
42 test("a 304 keeps the copy and moves fetchedAt; a 200 replaces copy and ETag; a malformed ETag is ignored", async () => {
43 const dir = await mkdtemp(join(tmpdir(), "forge-etag-"));
44 const url = "http://127.0.0.1:1";
45 const seen: (string | null)[] = [];
46 let answer: () => Response = () => new Response();
47 const f = (async (_u: unknown, init?: RequestInit) => {
48 seen.push(new Headers(init?.headers).get("if-none-match"));
49 return answer();
50 }) as unknown as typeof fetch;
51 const doc = (name: string, etag: string) => () =>
52 new Response(
53 JSON.stringify({ releases: [{ name, url: "http://127.0.0.1/r" }] }),
54 { headers: { etag } },
55 );
56 const read = async () =>
57 JSON.parse(await Bun.file(join(dir, "parent-releases.json")).text());
58 try {
59 answer = doc("1.0.0", '"a"');
60 await refreshParentReleases(dir, url, { loopback: true, fetch: f, now: 1 });
61 expect((await read()).etag).toBe('"a"');
62 answer = () => new Response(null, { status: 304 });
63 await refreshParentReleases(dir, url, { loopback: true, fetch: f, now: 2 });
64 const kept = await read();
65 expect(seen).toEqual([null, '"a"']);
66 expect(kept.fetchedAt).toBe(2);
67 expect(kept.ok).toBe(true);
68 expect(kept.etag).toBe('"a"');
69 expect(kept.releases[0].name).toBe("1.0.0");
70 answer = doc("2.0.0", '"b"');
71 await refreshParentReleases(dir, url, { loopback: true, fetch: f, now: 3 });
72 const next = await read();
73 expect(next.etag).toBe('"b"');
74 expect(next.releases[0].name).toBe("2.0.0");
75 answer = doc("3.0.0", "W/bad");
76 await refreshParentReleases(dir, url, { loopback: true, fetch: f, now: 4 });
77 expect((await read()).etag).toBeUndefined();
78 await refreshParentReleases(dir, url, { loopback: true, fetch: f, now: 5 });
79 expect(seen.at(-1)).toBeNull();
80 } finally {
81 await rm(dir, { recursive: true, force: true });
82 }
83 });
test/standards.test.ts +20
@@ -105,6 +105,26 @@ describe("what the open web expects", () => {
105105 expect(read![0]!.url).toStartWith(`${base}/releases.json#`);
106106 });
107107
108 test("releases.json carries a strong ETag, and a matching If-None-Match is a 304 with no body", async () => {
109 const res = await fetch(`${base}/releases.json`);
110 const etag = res.headers.get("etag")!;
111 expect(etag).toMatch(/^"[0-9a-f]+"$/);
112 await res.text();
113 const same = await fetch(`${base}/releases.json`, {
114 headers: { "if-none-match": etag },
115 });
116 expect(same.status).toBe(304);
117 expect(await same.text()).toBe("");
118 expect(same.headers.get("etag")).toBe(etag);
119 expect(same.headers.get("cache-control")).toBe(
120 res.headers.get("cache-control"),
121 );
122 const other = await fetch(`${base}/releases.json`, {
123 headers: { "if-none-match": '"nope"' },
124 });
125 expect(other.status).toBe(200);
126 });
127
108128 test("robots keeps crawlers out of accounts and points at the sitemap", async () => {
109129 const body = await fetch(`${base}/robots.txt`).then((r) => r.text());
110130 expect(body).toContain("User-agent: *");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs