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