| 67 | 67 | | const objectUrl = (account, bucket, key) => `https://api.cloudflare.com/client/v4/accounts/${account}/r2/buckets/${bucket}/objects/${key}`; |
| 68 | 68 | | const manifestName = (names) => names.find((name) => /^manifest-[a-zA-Z0-9_-]{8}\.js$/.test(name)); |
| 69 | 69 | | |
| 70 | | − | async function confirmUpload(response, bytes, label) { |
| 70 | + | /** An ETag as `"<md5>"`, as `checksum` writes it: R2's REST gateway answers a GET with a weak one, `W/"<md5>"`. */ |
| 71 | + | const strong = (etag) => (etag ? `"${etag.replace(/^W\//, "").replaceAll('"', "")}"` : null); |
| 72 | + | |
| 73 | + | /** The stored object's ETag, or null when there is none. */ |
| 74 | + | async function storedEtag(url, headers, fetchImpl, label) { |
| 75 | + | // Cloudflare's R2 REST gateway does not implement HEAD. Range avoids |
| 76 | + | // downloading unchanged files; cancel the body even if Range is ignored. |
| 77 | + | const existing = await fetchImpl(url, { headers: { ...headers, range: "bytes=0-0" }, signal: AbortSignal.timeout(30_000) }); |
| 78 | + | await existing.body?.cancel(); |
| 79 | + | if (existing.ok) return strong(existing.headers.get("etag")); |
| 80 | + | if (existing.status !== 404) throw new Error(`Asset archive lookup failed (${existing.status}): ${label}`); |
| 81 | + | return null; |
| 82 | + | } |
| 83 | + | |
| 84 | + | /** `stored`, when given, reads the object's ETag: a 2xx whose receipt can't be read (empty or cut short) is confirmed by the object itself. */ |
| 85 | + | async function confirmUpload(response, bytes, label, stored) { |
| 71 | 86 | | if (!response.ok) { |
| 72 | 87 | | await response.body?.cancel(); |
| 73 | 88 | | throw new Error(`Asset archive upload failed (${response.status}): ${label}`); |
| ⋯ |
| 76 | 91 | | try { |
| 77 | 92 | | receipt = await response.json(); |
| 78 | 93 | | } catch { |
| 94 | + | if (stored && (await stored()) === checksum(bytes)) return; |
| 79 | 95 | | throw new Error(`Invalid asset upload receipt: ${label}`); |
| 80 | 96 | | } |
| 81 | 97 | | if (receipt.success !== true || `"${receipt.result?.etag?.replaceAll('"', "")}"` !== checksum(bytes)) |
| ⋯ |
| 117 | 133 | | if (index.get(key) !== expected) throw new Error(`Asset filename collision: ${name}`); |
| 118 | 134 | | return; |
| 119 | 135 | | } |
| 120 | | − | // Cloudflare's R2 REST gateway does not implement HEAD. Range avoids |
| 121 | | − | // downloading unchanged files; cancel the body even if Range is ignored. |
| 122 | | − | const existing = await fetchImpl(url, { headers: { ...headers, range: "bytes=0-0" }, signal: AbortSignal.timeout(30_000) }); |
| 123 | | − | if (existing.ok) { |
| 124 | | − | await existing.body?.cancel(); |
| 125 | | − | if (existing.headers.get("etag") !== expected) throw new Error(`Asset filename collision: ${name}`); |
| 136 | + | const stored = () => storedEtag(url, headers, fetchImpl, name); |
| 137 | + | const existing = await stored(); |
| 138 | + | if (existing) { |
| 139 | + | if (existing !== expected) throw new Error(`Asset filename collision: ${name}`); |
| 126 | 140 | | index?.set(key, expected); |
| 127 | 141 | | return; |
| 128 | 142 | | } |
| 129 | | − | await existing.body?.cancel(); |
| 130 | | − | if (existing.status !== 404) throw new Error(`Asset archive lookup failed (${existing.status}): ${name}`); |
| 131 | 143 | | const uploaded = await fetchImpl(url, { |
| 132 | 144 | | method: "PUT", |
| 133 | 145 | | headers: { ...headers, "content-type": TYPES[name.split(".").at(-1)], "cache-control": "public, max-age=31536000, immutable" }, |
| 134 | 146 | | body: bytes, |
| 135 | 147 | | signal: AbortSignal.timeout(60_000), |
| 136 | 148 | | }); |
| 137 | | − | await confirmUpload(uploaded, bytes, name); |
| 149 | + | await confirmUpload(uploaded, bytes, name, stored); |
| 138 | 150 | | index?.set(key, expected); |
| 139 | 151 | | } |
| 140 | 152 | | |