Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

244 lines · 11.4 KBRaw History
1import { createWriteStream } from "node:fs";
2import { open, stat } from "node:fs/promises";
3import { totalmem } from "node:os";
4import { Readable } from "node:stream";
5import { pipeline } from "node:stream/promises";
6import type { ReadableStream as WebReadableStream } from "node:stream/web";
7import type { JobSpec } from "@diffinery/shared";
8
9/**
10 * Client for the Actions runtime services (ACTIONS_RESULTS_URL): the Twirp
11 * cache and artifact services and their signed blob URLs. The built-in cache
12 * and artifact actions use it, so they share storage (and archive formats)
13 * with the official actions.
14 */
15
16export class RuntimeError extends Error {
17 constructor(
18 readonly status: number,
19 readonly code: string,
20 message: string,
21 ) {
22 super(message);
23 }
24}
25
26const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
27const retryable = (status: number) => status === 408 || status === 429 || status >= 500;
28
29/** Uploads at most this much in one request; larger files go up as blocks. */
30const SINGLE_SHOT = 32 * 1024 * 1024;
31const BLOCK_SIZE = 32 * 1024 * 1024;
32const UPLOAD_CONCURRENCY = 4;
33/** Downloads come in ranges of this size, this many at a time (measured on hosted runners: 16 of 8 MiB ran 2-5x faster than one stream). */
34const DOWNLOAD_CHUNK = 8 * 1024 * 1024;
35// Fewer on the smallest runner (1 GiB), so ranges waiting their turn hold at most 32 MiB there.
36const DOWNLOAD_CONCURRENCY = totalmem() < 2 * 1024 ** 3 ? 4 : 16;
37
38export class RuntimeClient {
39 constructor(
40 readonly token: string,
41 readonly resultsUrl: string,
42 private readonly opts: { retries?: number; minBackoffMs?: number } = {},
43 ) {}
44
45 static fromSpec(spec: JobSpec): RuntimeClient | null {
46 return spec.runtime ? new RuntimeClient(spec.runtime.token, spec.runtime.resultsUrl) : null;
47 }
48
49 async withRetries<T>(what: string, fn: () => Promise<T>): Promise<T> {
50 const retries = this.opts.retries ?? 5;
51 let last: unknown;
52 for (let attempt = 0; attempt <= retries; attempt++) {
53 if (attempt) await sleep((this.opts.minBackoffMs ?? 1000) * 2 ** (attempt - 1));
54 try {
55 return await fn();
56 } catch (e) {
57 if (e instanceof RuntimeError && !retryable(e.status)) throw e;
58 last = e;
59 }
60 }
61 throw last instanceof Error ? new Error(`${what}: ${last.message}`) : new Error(`${what} failed`);
62 }
63
64 /** Calls a Twirp method (JSON). Errors become RuntimeError with the Twirp code. */
65 async twirp<T>(service: "CacheService" | "ArtifactService", method: string, body: Record<string, unknown>): Promise<T> {
66 const url = `${new URL(this.resultsUrl).origin}/twirp/github.actions.results.api.v1.${service}/${method}`;
67 return this.withRetries(method, async () => {
68 const res = await fetch(url, {
69 method: "POST",
70 headers: { authorization: `Bearer ${this.token}`, "content-type": "application/json", "user-agent": "diffinery-runner" },
71 body: JSON.stringify(body),
72 });
73 const text = await res.text();
74 if (!res.ok) {
75 let code = "unknown";
76 let msg = text.slice(0, 300);
77 try {
78 const j = JSON.parse(text) as { code?: string; msg?: string };
79 code = j.code ?? code;
80 msg = j.msg ?? msg;
81 } catch {
82 /* not Twirp JSON */
83 }
84 throw new RuntimeError(res.status, code, `${method} failed (${res.status} ${code}): ${msg}`);
85 }
86 return JSON.parse(text) as T;
87 });
88 }
89
90 /** Uploads a file to a signed URL (Put Blob, or Put Block + Put Block List). */
91 async upload(url: string, file: string, contentType = "application/octet-stream"): Promise<number> {
92 const size = (await stat(file)).size;
93 const fh = await open(file, "r");
94 try {
95 const read = async (offset: number, length: number) => {
96 const buf = Buffer.alloc(length);
97 let got = 0;
98 while (got < length) {
99 const { bytesRead } = await fh.read(buf, got, length - got, offset + got);
100 if (bytesRead === 0) break;
101 got += bytesRead;
102 }
103 return buf.subarray(0, got);
104 };
105 const put = (target: string, body: Buffer, headers: Record<string, string>) =>
106 this.withRetries("upload", async () => {
107 const res = await fetch(target, { method: "PUT", headers: { "x-ms-version": "2025-01-05", ...headers }, body });
108 if (res.status !== 201) {
109 const text = await res.text().catch(() => "");
110 throw new RuntimeError(res.status, "upload", `upload failed (${res.status}): ${text.slice(0, 300)}`);
111 }
112 });
113 if (size <= SINGLE_SHOT) {
114 await put(url, await read(0, size), { "x-ms-blob-type": "BlockBlob", "x-ms-blob-content-type": contentType, "content-type": "application/octet-stream" });
115 return size;
116 }
117 const blocks: string[] = [];
118 for (let off = 0, i = 0; off < size; off += BLOCK_SIZE, i++) blocks.push(Buffer.from(`block-${String(i).padStart(8, "0")}`).toString("base64"));
119 let next = 0;
120 const worker = async () => {
121 for (let i = next++; i < blocks.length; i = next++) {
122 const body = await read(i * BLOCK_SIZE, Math.min(BLOCK_SIZE, size - i * BLOCK_SIZE));
123 await put(`${url}&comp=block&blockid=${encodeURIComponent(blocks[i]!)}`, body, { "content-type": "application/octet-stream" });
124 }
125 };
126 await Promise.all(Array.from({ length: Math.min(UPLOAD_CONCURRENCY, blocks.length) }, worker));
127 const xml = `<?xml version="1.0" encoding="utf-8"?><BlockList>${blocks.map((b) => `<Latest>${b}</Latest>`).join("")}</BlockList>`;
128 await put(`${url}&comp=blocklist`, Buffer.from(xml), { "x-ms-blob-content-type": contentType, "content-type": "application/xml" });
129 return size;
130 } finally {
131 await fh.close();
132 }
133 }
134
135 /**
136 * Downloads a signed URL to a file. Large blobs come down in ranges, several at once, which is
137 * what makes a cache or artifact of a few hundred MB restore in seconds (one stream from R2 through
138 * the Worker often runs at 20-30 MB/s). Returns the response's content type and file name.
139 */
140 async download(url: string, file: string): Promise<{ contentType: string; fileName: string | null }> {
141 const out = createWriteStream(file);
142 const dl = this.downloadStream(url);
143 await pipeline(dl, out);
144 return dl.meta!;
145 }
146
147 /**
148 * The blob at a signed URL as a stream, in order. It asks for the first range; a server that
149 * answers with ranges (as Diffinery's does) gets the rest DOWNLOAD_CONCURRENCY at a time, each
150 * range retried on its own, and at most that many ranges are held in memory. A server that
151 * ignores ranges sends the whole body in one response. Every range must come from the same
152 * object (size and ETag), or the stream fails rather than mix two versions.
153 */
154 downloadStream(url: string, o: { onSize?: (size: number) => void } = {}): Readable & { meta?: { contentType: string; fileName: string | null }; size?: number } {
155 const abort = new AbortController();
156 const client = this;
157 const get = (range: { start: number; end: number } | null) =>
158 fetch(url, { headers: { "user-agent": "diffinery-runner", ...(range ? { range: `bytes=${range.start}-${range.end}` } : {}) }, signal: abort.signal });
159 const fail = async (res: Response) => {
160 const text = await res.text().catch(() => "");
161 return new RuntimeError(res.status, "download", `download failed (${res.status}): ${text.slice(0, 200)}`);
162 };
163 async function* chunks(this: void): AsyncGenerator<Buffer> {
164 // The first range, or the whole body from a server that doesn't do ranges (or an empty blob: 416).
165 const first = await client.withRetries("download", async () => {
166 let res = await get({ start: 0, end: DOWNLOAD_CHUNK - 1 });
167 if (res.status === 416) {
168 await res.body?.cancel();
169 res = await get(null);
170 }
171 if (!res.ok || !res.body) throw await fail(res);
172 return res;
173 });
174 stream.meta = { contentType: (first.headers.get("content-type") ?? "").split(";")[0]!.trim().toLowerCase(), fileName: fileNameOf(first.headers.get("content-disposition") ?? "") };
175 const total = first.status === 206 ? Number(/\/(\d+)$/.exec(first.headers.get("content-range") ?? "")?.[1]) : NaN;
176 if (first.status !== 206 || !Number.isSafeInteger(total)) {
177 const declared = first.headers.get("content-length");
178 // fetch decodes a compressed body, so its length only counts when the body isn't encoded.
179 const length = declared === null || first.headers.get("content-encoding") ? NaN : Number(declared);
180 if (Number.isSafeInteger(length)) o.onSize?.(length);
181 let n = 0;
182 for await (const c of Readable.fromWeb(first.body as unknown as WebReadableStream)) {
183 n += (c as Buffer).length;
184 yield c as Buffer;
185 }
186 if (Number.isSafeInteger(length) && n !== length) throw new Error(`download: got ${n} of ${length} bytes`);
187 stream.size = n;
188 return;
189 }
190 stream.size = total;
191 o.onSize?.(total);
192 const etag = first.headers.get("etag");
193 const ranges: Array<{ start: number; end: number }> = [];
194 for (let start = 0; start < total; start += DOWNLOAD_CHUNK) ranges.push({ start, end: Math.min(start + DOWNLOAD_CHUNK, total) - 1 });
195 const body = async (res: Response, r: { start: number; end: number }) => {
196 const buf = Buffer.from(await res.arrayBuffer());
197 if (buf.length !== r.end - r.start + 1) throw new Error(`download: got ${buf.length} bytes for bytes ${r.start}-${r.end}`);
198 return buf;
199 };
200 const fetchRange = (r: { start: number; end: number }) =>
201 client.withRetries("download", async () => {
202 const res = await get(r);
203 if (res.status !== 206) {
204 await res.body?.cancel();
205 throw res.ok ? new RuntimeError(412, "download", `download failed: the server stopped answering with ranges (${res.status})`) : await fail(res);
206 }
207 const m = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(res.headers.get("content-range") ?? "");
208 if (!m || Number(m[1]) !== r.start || Number(m[2]) !== r.end || Number(m[3]) !== total || (etag && res.headers.get("etag") !== etag)) {
209 await res.body?.cancel();
210 throw new RuntimeError(412, "download", "download failed: the blob changed while it was being downloaded");
211 }
212 return body(res, r);
213 });
214 const pending: Array<Promise<Buffer>> = [body(first, ranges[0]!).catch(() => fetchRange(ranges[0]!))];
215 let next = 1;
216 const fill = () => {
217 while (next < ranges.length && pending.length < DOWNLOAD_CONCURRENCY) {
218 const p = fetchRange(ranges[next++]!);
219 p.catch(() => {}); // awaited in order below; this keeps an early failure from going unhandled meanwhile
220 pending.push(p);
221 }
222 };
223 fill();
224 while (pending.length) {
225 const buf = await pending.shift()!;
226 fill();
227 yield buf;
228 }
229 }
230 const stream: Readable & { meta?: { contentType: string; fileName: string | null }; size?: number } = Readable.from(chunks(), { objectMode: false });
231 stream.once("close", () => abort.abort());
232 return stream;
233 }
234}
235
236function fileNameOf(cd: string): string | null {
237 const star = /filename\*\s*=\s*UTF-8''([^;\r\n]*)/i.exec(cd)?.[1];
238 const plain = /(?<!\*)filename\s*=\s*"?([^;\r\n"]*)"?/i.exec(cd)?.[1];
239 try {
240 return star ? decodeURIComponent(star) : (plain ?? null);
241 } catch {
242 return plain ?? null;
243 }
244}