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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3755-a-child-forge-s-check-of-its-parent-s-re → main 1 commit fork 1 thread · 1 unresolved
4 files
changed +136
−16
src/core/parent_releases.ts +45 −9
| @@ -110,6 +110,8 @@ interface Stored { | ||
| 110 | 110 | triedAt: number; |
| 111 | 111 | ok: boolean; |
| 112 | 112 | releases: ParentRelease[]; |
| 113 | /** The parent's ETag for `releases`, when it sent a well-formed one. */ | |
| 114 | etag?: string; | |
| 113 | 115 | } |
| 114 | 116 | |
| 115 | 117 | function readStored(file: string, url: string): Stored | undefined { |
| @@ -122,13 +124,30 @@ function readStored(file: string, url: string): Stored | undefined { | ||
| 122 | 124 | } |
| 123 | 125 | } |
| 124 | 126 | |
| 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 }> { | |
| 126 | 140 | const res = await f(`${url.replace(/\/+$/, "")}/releases.json`, { |
| 127 | 141 | signal: AbortSignal.timeout(TIMEOUT_MS), |
| 128 | 142 | redirect: "error", |
| 129 | headers: { accept: "application/json" }, | |
| 143 | headers: { | |
| 144 | accept: "application/json", | |
| 145 | ...(etag === undefined ? {} : { "if-none-match": etag }), | |
| 146 | }, | |
| 130 | 147 | }); |
| 148 | if (res.status === 304 && etag !== undefined) return { unchanged: true }; | |
| 131 | 149 | if (!res.ok || res.body === null) throw new Error(`status ${res.status}`); |
| 150 | const tag = strongEtag(res.headers.get("etag")); | |
| 132 | 151 | const reader = res.body.getReader(); |
| 133 | 152 | const chunks: Uint8Array[] = []; |
| 134 | 153 | let size = 0; |
| @@ -142,7 +161,10 @@ async function fetchDoc(url: string, f: typeof fetch): Promise<unknown> { | ||
| 142 | 161 | } |
| 143 | 162 | chunks.push(value); |
| 144 | 163 | } |
| 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 | }; | |
| 146 | 168 | } |
| 147 | 169 | |
| 148 | 170 | const refreshing = new Map<string, Promise<void>>(); |
| @@ -173,12 +195,23 @@ export function refreshParentReleases( | ||
| 173 | 195 | const copy = kept?.fetchedAt ?? null; |
| 174 | 196 | let next: Stored; |
| 175 | 197 | 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 | } | |
| 182 | 215 | } catch { |
| 183 | 216 | next = { |
| 184 | 217 | url, |
| @@ -186,6 +219,9 @@ export function refreshParentReleases( | ||
| 186 | 219 | triedAt: now, |
| 187 | 220 | ok: false, |
| 188 | 221 | releases: copy === null ? [] : (kept?.releases ?? []), |
| 222 | ...(copy === null || kept?.etag === undefined | |
| 223 | ? {} | |
| 224 | : { etag: kept.etag }), | |
| 189 | 225 | }; |
| 190 | 226 | } |
| 191 | 227 | try { |
src/web/standards.ts +28 −7
| @@ -100,15 +100,36 @@ export function changePassword(): Response { | ||
| 100 | 100 | * release to point at. |
| 101 | 101 | */ |
| 102 | 102 | 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"); | |
| 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; | |
| 110 | 128 | } |
| 111 | 129 | |
| 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 | ||
| 112 | 133 | /** GET /robots.txt */ |
| 113 | 134 | export function robotsTxt(ctx: Ctx): Response { |
| 114 | 135 | 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 | ||
| 38 | 38 | await rm(dir, { recursive: true, force: true }); |
| 39 | 39 | } |
| 40 | 40 | }); |
| 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", () => { | ||
| 105 | 105 | expect(read![0]!.url).toStartWith(`${base}/releases.json#`); |
| 106 | 106 | }); |
| 107 | 107 | |
| 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 | ||
| 108 | 128 | test("robots keeps crawlers out of accounts and points at the sitemap", async () => { |
| 109 | 129 | const body = await fetch(`${base}/robots.txt`).then((r) => r.text()); |
| 110 | 130 | expect(body).toContain("User-agent: *"); |
new above
offline
Duplicates the cache-control string hard-coded in text(). Share one constant so the 200 and 304 cannot drift.