| 1 | // Public, fingerprinted files outlive a Worker release. Upload before cutover; |
| 2 | // never delete or overwrite a filename with different bytes. |
| 3 | import { createHash, randomUUID } from "node:crypto"; |
| 4 | import { readFile, readdir } from "node:fs/promises"; |
| 5 | import { join, resolve } from "node:path"; |
| 6 | import { pathToFileURL } from "node:url"; |
| 7 | import { archivedAssetKey } from "../../apps/web/app/lib/asset-archive.ts"; |
| 8 | import { cloudflareAuth, jsonFrom, wrangler, wranglerEnv } from "./cloudflare.mjs"; |
| 9 | |
| 10 | const TYPES = { js: "text/javascript", css: "text/css", woff: "font/woff", woff2: "font/woff2", ttf: "font/ttf", otf: "font/otf", svg: "image/svg+xml", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", ico: "image/x-icon", wasm: "application/wasm" }; |
| 11 | |
| 12 | export function assetReferences(text) { |
| 13 | return [...new Set([...text.matchAll(/(?:\/?assets\/|["'(]\.\/)([a-zA-Z0-9_.~-]+\.(?:js|css|woff2?|ttf|otf|png|jpe?g|gif|svg|webp|ico|wasm))\b/g)].map((match) => match[1]))] |
| 14 | // Vite's emitted filenames have an eight-character content hash. |
| 15 | // Libraries can also contain unused relative defaults like elk-worker.min.js. |
| 16 | .filter((name) => /-[a-zA-Z0-9_-]{8}\.[a-z0-9]+$/.test(name)); |
| 17 | } |
| 18 | |
| 19 | async function together(items, run) { |
| 20 | let index = 0; |
| 21 | const results = await Promise.allSettled(Array.from({ length: Math.min(6, items.length) }, async () => { |
| 22 | for (let i = index++; i < items.length; i = index++) await run(items[i]); |
| 23 | })); |
| 24 | const failed = results.find((result) => result.status === "rejected"); |
| 25 | if (failed) throw failed.reason; |
| 26 | } |
| 27 | |
| 28 | /** Bounded provider retries: archive trouble delays cutover, never skips it. */ |
| 29 | export function retryingFetch(fetchImpl = fetch, wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) { |
| 30 | return async (url, options = {}) => { |
| 31 | for (let attempt = 0; ; attempt++) { |
| 32 | const response = await fetchImpl(url, { ...options, signal: AbortSignal.timeout(60_000) }); |
| 33 | if (![429, 500, 502, 503, 504].includes(response.status) || attempt === 6) return response; |
| 34 | const seconds = Number(response.headers.get("retry-after")); |
| 35 | await response.body?.cancel(); |
| 36 | await wait(response.status === 429 ? Math.min(60_000, Math.max(1000, seconds * 1000 || 60_000)) : Math.min(1000 * 2 ** attempt, 30_000)); |
| 37 | } |
| 38 | }; |
| 39 | } |
| 40 | |
| 41 | const checksum = (bytes) => `"${createHash("md5").update(bytes).digest("hex")}"`; |
| 42 | const objectUrl = (account, bucket, key) => `https://api.cloudflare.com/client/v4/accounts/${account}/r2/buckets/${bucket}/objects/${key}`; |
| 43 | const manifestName = (names) => names.find((name) => /^manifest-[a-zA-Z0-9_-]{8}\.js$/.test(name)); |
| 44 | |
| 45 | async function confirmUpload(response, bytes, label) { |
| 46 | if (!response.ok) { await response.body?.cancel(); throw new Error(`Asset archive upload failed (${response.status}): ${label}`); } |
| 47 | let receipt; |
| 48 | try { receipt = await response.json(); } catch { throw new Error(`Invalid asset upload receipt: ${label}`); } |
| 49 | if (receipt.success !== true || `"${receipt.result?.etag?.replaceAll('"', "")}"` !== checksum(bytes)) throw new Error(`Asset upload checksum mismatch: ${label}`); |
| 50 | } |
| 51 | |
| 52 | /** One manifest per release: work never grows with the number of old releases. */ |
| 53 | export async function readArchiveManifest(url, headers, fetchImpl = fetch) { |
| 54 | const response = await fetchImpl(url, { headers }); |
| 55 | if (response.status === 404) { await response.body?.cancel(); return null; } |
| 56 | if (!response.ok) throw new Error(`Cannot read archived build (${response.status})`); |
| 57 | const data = await response.json(); |
| 58 | if (data.version !== 1 || !Array.isArray(data.assets) || !data.assets.length || data.assets.some((row) => !Array.isArray(row) || row.length !== 2 || typeof row[0] !== "string" || archivedAssetKey(row[0].replace(/^web-assets/, "")) !== row[0] || !/^"[a-f0-9]{32}"$/.test(row[1]))) throw new Error("Invalid archived build manifest"); |
| 59 | return new Map(data.assets); |
| 60 | } |
| 61 | |
| 62 | export async function archiveAsset({ name, bytes, account, bucket, headers, index, fetchImpl = fetch }) { |
| 63 | const key = archivedAssetKey(`/assets/${name}`); |
| 64 | if (!key) throw new Error(`Not a public build asset: ${name}`); |
| 65 | const url = objectUrl(account, bucket, key); |
| 66 | const expected = checksum(bytes); |
| 67 | if (index?.has(key)) { |
| 68 | if (index.get(key) !== expected) throw new Error(`Asset filename collision: ${name}`); |
| 69 | return; |
| 70 | } |
| 71 | // Cloudflare's R2 REST gateway does not implement HEAD. Range avoids |
| 72 | // downloading unchanged files; cancel the body even if Range is ignored. |
| 73 | const existing = await fetchImpl(url, { headers: { ...headers, range: "bytes=0-0" }, signal: AbortSignal.timeout(30_000) }); |
| 74 | if (existing.ok) { |
| 75 | await existing.body?.cancel(); |
| 76 | if (existing.headers.get("etag") !== expected) throw new Error(`Asset filename collision: ${name}`); |
| 77 | index?.set(key, expected); |
| 78 | return; |
| 79 | } |
| 80 | await existing.body?.cancel(); |
| 81 | if (existing.status !== 404) throw new Error(`Asset archive lookup failed (${existing.status}): ${name}`); |
| 82 | const uploaded = await fetchImpl(url, { |
| 83 | method: "PUT", headers: { ...headers, "content-type": TYPES[name.split(".").at(-1)], "cache-control": "public, max-age=31536000, immutable" }, |
| 84 | body: bytes, signal: AbortSignal.timeout(60_000), |
| 85 | }); |
| 86 | await confirmUpload(uploaded, bytes, name); |
| 87 | index?.set(key, expected); |
| 88 | } |
| 89 | |
| 90 | /** Seed the currently live build too, so the first protected release saves existing tabs. */ |
| 91 | async function liveReferences(origin, fetchImpl) { |
| 92 | const pageUrl = new URL(origin); |
| 93 | // The application's public HTML cache may still contain a previous release. |
| 94 | pageUrl.searchParams.set("_g1t_archive", randomUUID()); |
| 95 | const page = await fetchImpl(pageUrl, { signal: AbortSignal.timeout(30_000) }); |
| 96 | if (!page.ok) throw new Error(`Cannot read the current site before cutover (${page.status})`); |
| 97 | const pending = assetReferences(await page.text()); |
| 98 | if (!pending.some((name) => name.endsWith(".js")) || !pending.some((name) => name.endsWith(".css"))) throw new Error("Current site did not expose its JS and CSS assets; refusing an unprotected cutover"); |
| 99 | return pending; |
| 100 | } |
| 101 | |
| 102 | export async function archiveLiveAssets(origin, save, fetchImpl = fetch, references) { |
| 103 | let pending = references ?? await liveReferences(origin, fetchImpl); |
| 104 | const seen = new Set(); |
| 105 | while (pending.length) { |
| 106 | const batch = [...new Set(pending)].filter((name) => !seen.has(name)); |
| 107 | pending = []; |
| 108 | for (const name of batch) seen.add(name); |
| 109 | if (seen.size > 4000) throw new Error("Current site's asset graph exceeds the archive limit"); |
| 110 | await together(batch, async (name) => { |
| 111 | const response = await fetchImpl(new URL(`/assets/${name}`, origin), { signal: AbortSignal.timeout(30_000) }); |
| 112 | if (!response.ok || /text\/html/i.test(response.headers.get("content-type") ?? "")) throw new Error(`Current asset is unavailable: ${name}`); |
| 113 | const bytes = Buffer.from(await response.arrayBuffer()); |
| 114 | await save(name, bytes); |
| 115 | if (/\.(js|css)$/.test(name)) pending.push(...assetReferences(bytes.toString("utf8"))); |
| 116 | }); |
| 117 | } |
| 118 | return seen.size; |
| 119 | } |
| 120 | |
| 121 | export async function publishWebAssets(cwd, { onLine = console.log, fetchImpl = fetch, seedLive = true, auth = cloudflareAuth(wranglerEnv()) } = {}) { |
| 122 | const config = JSON.parse(await readFile(join(cwd, "build/server/wrangler.json"), "utf8")); |
| 123 | const bucket = config.r2_buckets.find((binding) => binding.binding === "DOWNLOADS")?.bucket_name; |
| 124 | if (!bucket || !config.account_id) throw new Error("Web asset archive needs the DOWNLOADS bucket and Cloudflare account"); |
| 125 | let headers = auth; |
| 126 | if (!headers) { |
| 127 | // Capture credentials only in memory; never pipe this output to deploy logs. |
| 128 | const auth = await wrangler(["auth", "token", "--json"], { cwd }); |
| 129 | if (auth.code !== 0) throw new Error("Cannot authenticate the asset archive; run wrangler login"); |
| 130 | let credentials; |
| 131 | try { credentials = jsonFrom(auth.out); } catch { throw new Error("Cannot read Wrangler credentials for asset archive"); } |
| 132 | headers = credentials.type === "api_key" ? { "x-auth-key": credentials.key, "x-auth-email": credentials.email } : { authorization: `Bearer ${credentials.token}` }; |
| 133 | } |
| 134 | const providerFetch = retryingFetch(fetchImpl); |
| 135 | const manifestUrl = (name) => objectUrl(config.account_id, bucket, `web-assets/manifests/${name}.json`); |
| 136 | const saveManifest = async (name, assets) => { |
| 137 | const body = JSON.stringify({ version: 1, assets: [...assets] }); |
| 138 | const response = await providerFetch(manifestUrl(name), { method: "PUT", headers: { ...headers, "content-type": "application/json" }, body }); |
| 139 | await confirmUpload(response, body, name); |
| 140 | }; |
| 141 | let index = new Map(); |
| 142 | const domain = config.routes?.find((route) => route.custom_domain)?.pattern; |
| 143 | if (domain && seedLive) { |
| 144 | const origin = `https://${domain}`; |
| 145 | const references = await liveReferences(origin, fetchImpl); |
| 146 | const manifest = manifestName(references); |
| 147 | if (!manifest) throw new Error("Current site has no versioned route manifest"); |
| 148 | const archived = await readArchiveManifest(manifestUrl(manifest), headers, providerFetch); |
| 149 | if (archived) { |
| 150 | index = archived; |
| 151 | onLine(`Current build is already archived (${index.size} assets)`); |
| 152 | } else { |
| 153 | const save = (name, bytes) => archiveAsset({ name, bytes, account: config.account_id, bucket, headers, index, fetchImpl: providerFetch }); |
| 154 | onLine(`Archived ${await archiveLiveAssets(origin, save, fetchImpl, references)} current assets before cutover`); |
| 155 | await saveManifest(manifest, index); |
| 156 | } |
| 157 | } |
| 158 | const directory = join(cwd, "build/client/assets"); |
| 159 | const previous = new Map(index); |
| 160 | const names = (await readdir(directory)).filter((name) => archivedAssetKey(`/assets/${name}`)); |
| 161 | const manifest = manifestName(names); |
| 162 | if (!manifest || !names.some((name) => name.endsWith(".css"))) throw new Error("Web build has no manifest/CSS assets"); |
| 163 | const current = new Map(); |
| 164 | await together(names, async (name) => { |
| 165 | const bytes = await readFile(join(directory, name)); |
| 166 | await archiveAsset({ name, bytes, account: config.account_id, bucket, headers, index, fetchImpl: providerFetch }); |
| 167 | current.set(archivedAssetKey(`/assets/${name}`), checksum(bytes)); |
| 168 | }); |
| 169 | await saveManifest(manifest, current); |
| 170 | onLine(`Verified ${names.length} assets for the new build; old filenames remain available`); |
| 171 | const samples = new Map(); |
| 172 | for (const assets of [previous, current]) { |
| 173 | for (const pattern of [/\.css$/, /\/entry\.client-.*\.js$/, /\/manifest-.*\.js$/]) { |
| 174 | const entry = [...assets].find(([key]) => pattern.test(key)); |
| 175 | if (entry) samples.set(...entry); |
| 176 | } |
| 177 | } |
| 178 | return { origin: domain ? `https://${domain}` : null, manifest, samples: [...samples] }; |
| 179 | } |
| 180 | |
| 181 | /** Check both sides of cutover, including filenames no longer in the new bundle. */ |
| 182 | export async function verifyWebAssets(release, fetchImpl = fetch) { |
| 183 | if (!release.origin) return; |
| 184 | const references = await liveReferences(release.origin, fetchImpl); |
| 185 | if (!references.includes(release.manifest)) throw new Error("Web cutover is not serving the expected build"); |
| 186 | await together(release.samples, async ([key, etag]) => { |
| 187 | const response = await fetchImpl(new URL(key.replace(/^web-assets/, ""), release.origin), { signal: AbortSignal.timeout(30_000) }); |
| 188 | if (!response.ok || /text\/html/i.test(response.headers.get("content-type") ?? "") || checksum(Buffer.from(await response.arrayBuffer())) !== etag) throw new Error(`Web cutover asset check failed: ${key}`); |
| 189 | }); |
| 190 | } |
| 191 | |
| 192 | if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { |
| 193 | const release = await publishWebAssets(process.cwd()); |
| 194 | if (process.argv.includes("--deploy")) { |
| 195 | const deployed = await wrangler(["deploy"], { cwd: process.cwd(), onLine: console.log }); |
| 196 | if (deployed.code !== 0) throw new Error("Web deploy failed"); |
| 197 | await verifyWebAssets(release); |
| 198 | console.log("Web cutover verified: current and previous build assets respond"); |
| 199 | } |
| 200 | } |