Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

221 linesCodeBlameRaw
1// Public, fingerprinted files outlive a Worker release. Upload before cutover;
2// never delete or overwrite a filename with different bytes.
3import { createHash, randomUUID } from "node:crypto";
4import { readFile, readdir } from "node:fs/promises";
5import { join, resolve } from "node:path";
6import { pathToFileURL } from "node:url";
7import { archivedAssetKey } from "../../apps/web/app/lib/asset-archive.ts";
8import { cloudflareAuth, jsonFrom, wrangler, wranglerEnv } from "./cloudflare.mjs";
9
10const 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
12export 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
19async 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. */
29export 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
41const checksum = (bytes) => `"${createHash("md5").update(bytes).digest("hex")}"`;
42const objectUrl = (account, bucket, key) => `https://api.cloudflare.com/client/v4/accounts/${account}/r2/buckets/${bucket}/objects/${key}`;
43const manifestName = (names) => names.find((name) => /^manifest-[a-zA-Z0-9_-]{8}\.js$/.test(name));
44
45async 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. */
53export 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
62export 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. */
91async 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
102export 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
121export 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. */
182async function verifyWebAssetsOnce(release, fetchImpl) {
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 (expected ${release.manifest}, observed ${manifestName(references) ?? "no manifest"})`);
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/** Wrangler finishing does not mean every edge is already serving the new version. */
193export async function verifyWebAssets(release, fetchImpl = fetch, {
194 retryWaits = [1000, 2000, 4000, 8000, 15000, 30000],
195 wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
196 onLine = () => {},
197} = {}) {
198 const deadline = AbortSignal.timeout(120_000);
199 const boundedFetch = (url, options = {}) => fetchImpl(url, { ...options, signal: options.signal ? AbortSignal.any([deadline, options.signal]) : deadline });
200 for (let attempt = 0; ; attempt++) {
201 try {
202 await verifyWebAssetsOnce(release, boundedFetch);
203 return;
204 } catch (error) {
205 if (attempt >= retryWaits.length || deadline.aborted) throw error;
206 onLine(`Waiting for web cutover (${retryWaits[attempt] / 1000}s): ${error.message}`);
207 await wait(retryWaits[attempt]);
208 deadline.throwIfAborted();
209 }
210 }
211}
212
213if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
214 const release = await publishWebAssets(process.cwd());
215 if (process.argv.includes("--deploy")) {
216 const deployed = await wrangler(["deploy"], { cwd: process.cwd(), onLine: console.log });
217 if (deployed.code !== 0) throw new Error("Web deploy failed");
218 await verifyWebAssets(release, fetch, { onLine: console.log });
219 console.log("Web cutover verified: current and previous build assets respond");
220 }
221}