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.

102 lines · 4.5 KBRaw History
1import type { Env } from "../env.js";
2import { Sha256 } from "../lib/sha256.js";
3import { tailKey, type UploadRow } from "./store.js";
4
5/**
6 * Blob uploads stream into R2. Small blobs become one object; bigger ones become a
7 * multipart upload with fixed-size parts (R2 wants every part but the last to be the
8 * same size). Bytes that don't fill a part yet wait in a tail object until the next
9 * chunk or the final PUT. The object key is the upload's, not the digest's: R2 can't
10 * rename, so the blob index in D1 records where each digest's bytes are.
11 */
12export const PART_SIZE = 10 * 1024 * 1024;
13
14export class UploadError extends Error {}
15
16const hex = (b: ArrayBuffer) => [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, "0")).join("");
17
18/**
19 * Appends a request body to an upload, hashing it on the way through. The hash's progress
20 * is kept with the upload, so a chunked upload's last request doesn't read the whole blob
21 * back to hash it (for a big layer that took longer than clients wait for a response).
22 * Uploads started before the state was kept are hashed when they finish.
23 */
24export async function ingest(env: Env, u: UploadRow, body: ReadableStream<Uint8Array> | null, final: boolean): Promise<UploadRow> {
25 const bucket = env.BUCKET;
26 const parts = JSON.parse(u.parts) as R2UploadedPart[];
27 let mp = u.multipart_id ? bucket.resumeMultipartUpload(u.r2_key, u.multipart_id) : null;
28 let buf = new Uint8Array(PART_SIZE);
29 let fill = 0;
30
31 // Bring back the bytes that are not in a finished part yet.
32 const pending = mp ? u.tail : u.size;
33 if (pending > 0) {
34 const obj = await bucket.get(mp ? tailKey(u.id) : u.r2_key);
35 if (!obj) throw new UploadError("the upload's data is missing; start the upload again");
36 const bytes = new Uint8Array(await obj.arrayBuffer());
37 buf.set(bytes);
38 fill = bytes.length;
39 }
40
41 const hasher = u.size === 0 ? new Sha256() : u.hash_state ? Sha256.restore(u.hash_state) : null;
42 let added = 0;
43 const flush = async () => {
44 mp ??= await bucket.createMultipartUpload(u.r2_key, { httpMetadata: { contentType: "application/octet-stream" } });
45 parts.push(await mp.uploadPart(parts.length + 1, buf));
46 buf = new Uint8Array(PART_SIZE);
47 fill = 0;
48 };
49
50 if (body) {
51 const reader = body.getReader();
52 for (;;) {
53 const { done, value } = await reader.read();
54 if (done) break;
55 if (!value?.length) continue;
56 added += value.length;
57 hasher?.update(value);
58 for (let off = 0; off < value.length; ) {
59 const n = Math.min(PART_SIZE - fill, value.length - off);
60 buf.set(value.subarray(off, off + n), fill);
61 fill += n;
62 off += n;
63 if (fill === PART_SIZE) await flush();
64 }
65 }
66 }
67 const digest = final && hasher ? `sha256:${hasher.hex()}` : null;
68
69 const hadTail = !!u.multipart_id && u.tail > 0;
70 if (final) {
71 if (mp) {
72 if (fill > 0 || !parts.length) parts.push(await mp.uploadPart(parts.length + 1, buf.subarray(0, fill)));
73 await mp.complete(parts);
74 if (hadTail) await bucket.delete(tailKey(u.id));
75 } else if (added > 0 || u.size === 0) {
76 await bucket.put(u.r2_key, buf.subarray(0, fill), { httpMetadata: { contentType: "application/octet-stream" } });
77 }
78 } else if (mp) {
79 if (fill > 0) await bucket.put(tailKey(u.id), buf.subarray(0, fill));
80 else if (hadTail) await bucket.delete(tailKey(u.id));
81 } else if (added > 0) {
82 await bucket.put(u.r2_key, buf.subarray(0, fill));
83 }
84 // A small upload that grew into a multipart one: its first bytes went into part 1, so the
85 // single object written by an earlier chunk is replaced when the multipart upload completes.
86 return { ...u, multipart_id: mp?.uploadId ?? null, parts: JSON.stringify(parts), size: u.size + added, tail: mp && !final ? fill : 0, digest, hash_state: hasher && !final ? hasher.save() : null };
87}
88
89/** SHA-256 of a stored object, for uploads that arrived in several requests. */
90export async function digestOf(env: Env, key: string): Promise<{ digest: string; size: number } | null> {
91 const obj = await env.BUCKET.get(key);
92 if (!obj) return null;
93 const ds = new crypto.DigestStream("SHA-256");
94 await obj.body.pipeTo(ds);
95 return { digest: `sha256:${hex(await ds.digest)}`, size: obj.size };
96}
97
98/** Throws away an upload's bytes. */
99export async function discard(env: Env, u: UploadRow): Promise<void> {
100 if (u.multipart_id) await env.BUCKET.resumeMultipartUpload(u.r2_key, u.multipart_id).abort().catch(() => {});
101 await env.BUCKET.delete([u.r2_key, tailKey(u.id)]);
102}