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.

292 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 = {
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
27export 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
42async 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. */
54export 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
66const checksum = (bytes) => `"${createHash("md5").update(bytes).digest("hex")}"`;
67const objectUrl = (account, bucket, key) => `https://api.cloudflare.com/client/v4/accounts/${account}/r2/buckets/${bucket}/objects/${key}`;
68const manifestName = (names) => names.find((name) => /^manifest-[a-zA-Z0-9_-]{8}\.js$/.test(name));
69
70/** An ETag as `"<md5>"`, as `checksum` writes it: R2's REST gateway answers a GET with a weak one, `W/"<md5>"`. */
71const strong = (etag) => (etag ? `"${etag.replace(/^W\//, "").replaceAll('"', "")}"` : null);
72
73/** The stored object's ETag, or null when there is none. */
74async 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. */
85async function confirmUpload(response, bytes, label, stored) {
86 if (!response.ok) {
87 await response.body?.cancel();
88 throw new Error(`Asset archive upload failed (${response.status}): ${label}`);
89 }
90 let receipt;
91 try {
92 receipt = await response.json();
93 } catch {
94 if (stored && (await stored()) === checksum(bytes)) return;
95 throw new Error(`Invalid asset upload receipt: ${label}`);
96 }
97 if (receipt.success !== true || `"${receipt.result?.etag?.replaceAll('"', "")}"` !== checksum(bytes))
98 throw new Error(`Asset upload checksum mismatch: ${label}`);
99}
100
101/** One manifest per release: work never grows with the number of old releases. */
102export async function readArchiveManifest(url, headers, fetchImpl = fetch) {
103 const response = await fetchImpl(url, { headers });
104 if (response.status === 404) {
105 await response.body?.cancel();
106 return null;
107 }
108 if (!response.ok) throw new Error(`Cannot read archived build (${response.status})`);
109 const data = await response.json();
110 if (
111 data.version !== 1 ||
112 !Array.isArray(data.assets) ||
113 !data.assets.length ||
114 data.assets.some(
115 (row) =>
116 !Array.isArray(row) ||
117 row.length !== 2 ||
118 typeof row[0] !== "string" ||
119 archivedAssetKey(row[0].replace(/^web-assets/, "")) !== row[0] ||
120 !/^"[a-f0-9]{32}"$/.test(row[1]),
121 )
122 )
123 throw new Error("Invalid archived build manifest");
124 return new Map(data.assets);
125}
126
127export async function archiveAsset({ name, bytes, account, bucket, headers, index, fetchImpl = fetch }) {
128 const key = archivedAssetKey(`/assets/${name}`);
129 if (!key) throw new Error(`Not a public build asset: ${name}`);
130 const url = objectUrl(account, bucket, key);
131 const expected = checksum(bytes);
132 if (index?.has(key)) {
133 if (index.get(key) !== expected) throw new Error(`Asset filename collision: ${name}`);
134 return;
135 }
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}`);
140 index?.set(key, expected);
141 return;
142 }
143 const uploaded = await fetchImpl(url, {
144 method: "PUT",
145 headers: { ...headers, "content-type": TYPES[name.split(".").at(-1)], "cache-control": "public, max-age=31536000, immutable" },
146 body: bytes,
147 signal: AbortSignal.timeout(60_000),
148 });
149 await confirmUpload(uploaded, bytes, name, stored);
150 index?.set(key, expected);
151}
152
153/** Seed the currently live build too, so the first protected release saves existing tabs. */
154async function liveReferences(origin, fetchImpl) {
155 const pageUrl = new URL(origin);
156 // The application's public HTML cache may still contain a previous release.
157 pageUrl.searchParams.set("_g1t_archive", randomUUID());
158 const page = await fetchImpl(pageUrl, { signal: AbortSignal.timeout(30_000) });
159 if (!page.ok) throw new Error(`Cannot read the current site before cutover (${page.status})`);
160 const pending = assetReferences(await page.text());
161 if (!pending.some((name) => name.endsWith(".js")) || !pending.some((name) => name.endsWith(".css")))
162 throw new Error("Current site did not expose its JS and CSS assets; refusing an unprotected cutover");
163 return pending;
164}
165
166export async function archiveLiveAssets(origin, save, fetchImpl = fetch, references) {
167 let pending = references ?? (await liveReferences(origin, fetchImpl));
168 const seen = new Set();
169 while (pending.length) {
170 const batch = [...new Set(pending)].filter((name) => !seen.has(name));
171 pending = [];
172 for (const name of batch) seen.add(name);
173 if (seen.size > 4000) throw new Error("Current site's asset graph exceeds the archive limit");
174 await together(batch, async (name) => {
175 const response = await fetchImpl(new URL(`/assets/${name}`, origin), { signal: AbortSignal.timeout(30_000) });
176 if (!response.ok || /text\/html/i.test(response.headers.get("content-type") ?? "")) throw new Error(`Current asset is unavailable: ${name}`);
177 const bytes = Buffer.from(await response.arrayBuffer());
178 await save(name, bytes);
179 if (/\.(js|css)$/.test(name)) pending.push(...assetReferences(bytes.toString("utf8")));
180 });
181 }
182 return seen.size;
183}
184
185export async function publishWebAssets(cwd, { onLine = console.log, fetchImpl = fetch, seedLive = true, auth = cloudflareAuth(wranglerEnv()) } = {}) {
186 const config = JSON.parse(await readFile(join(cwd, "build/server/wrangler.json"), "utf8"));
187 const bucket = config.r2_buckets.find((binding) => binding.binding === "DOWNLOADS")?.bucket_name;
188 if (!bucket || !config.account_id) throw new Error("Web asset archive needs the DOWNLOADS bucket and Cloudflare account");
189 let headers = auth;
190 if (!headers) {
191 // Capture credentials only in memory; never pipe this output to deploy logs.
192 const auth = await wrangler(["auth", "token", "--json"], { cwd });
193 if (auth.code !== 0) throw new Error("Cannot authenticate the asset archive; run wrangler login");
194 let credentials;
195 try {
196 credentials = jsonFrom(auth.out);
197 } catch {
198 throw new Error("Cannot read Wrangler credentials for asset archive");
199 }
200 headers =
201 credentials.type === "api_key" ? { "x-auth-key": credentials.key, "x-auth-email": credentials.email } : { authorization: `Bearer ${credentials.token}` };
202 }
203 const providerFetch = retryingFetch(fetchImpl);
204 const manifestUrl = (name) => objectUrl(config.account_id, bucket, `web-assets/manifests/${name}.json`);
205 const saveManifest = async (name, assets) => {
206 const body = JSON.stringify({ version: 1, assets: [...assets] });
207 const response = await providerFetch(manifestUrl(name), { method: "PUT", headers: { ...headers, "content-type": "application/json" }, body });
208 await confirmUpload(response, body, name);
209 };
210 let index = new Map();
211 const domain = config.routes?.find((route) => route.custom_domain)?.pattern;
212 if (domain && seedLive) {
213 const origin = `https://${domain}`;
214 const references = await liveReferences(origin, fetchImpl);
215 const manifest = manifestName(references);
216 if (!manifest) throw new Error("Current site has no versioned route manifest");
217 const archived = await readArchiveManifest(manifestUrl(manifest), headers, providerFetch);
218 if (archived) {
219 index = archived;
220 onLine(`Current build is already archived (${index.size} assets)`);
221 } else {
222 const save = (name, bytes) => archiveAsset({ name, bytes, account: config.account_id, bucket, headers, index, fetchImpl: providerFetch });
223 onLine(`Archived ${await archiveLiveAssets(origin, save, fetchImpl, references)} current assets before cutover`);
224 await saveManifest(manifest, index);
225 }
226 }
227 const directory = join(cwd, "build/client/assets");
228 const previous = new Map(index);
229 const names = (await readdir(directory)).filter((name) => archivedAssetKey(`/assets/${name}`));
230 const manifest = manifestName(names);
231 if (!manifest || !names.some((name) => name.endsWith(".css"))) throw new Error("Web build has no manifest/CSS assets");
232 const current = new Map();
233 await together(names, async (name) => {
234 const bytes = await readFile(join(directory, name));
235 await archiveAsset({ name, bytes, account: config.account_id, bucket, headers, index, fetchImpl: providerFetch });
236 current.set(archivedAssetKey(`/assets/${name}`), checksum(bytes));
237 });
238 await saveManifest(manifest, current);
239 onLine(`Verified ${names.length} assets for the new build; old filenames remain available`);
240 const samples = new Map();
241 for (const assets of [previous, current]) {
242 for (const pattern of [/\.css$/, /\/entry\.client-.*\.js$/, /\/manifest-.*\.js$/]) {
243 const entry = [...assets].find(([key]) => pattern.test(key));
244 if (entry) samples.set(...entry);
245 }
246 }
247 return { origin: domain ? `https://${domain}` : null, manifest, samples: [...samples] };
248}
249
250/** Check both sides of cutover, including filenames no longer in the new bundle. */
251async function verifyWebAssetsOnce(release, fetchImpl) {
252 if (!release.origin) return;
253 const references = await liveReferences(release.origin, fetchImpl);
254 if (!references.includes(release.manifest))
255 throw new Error(`Web cutover is not serving the expected build (expected ${release.manifest}, observed ${manifestName(references) ?? "no manifest"})`);
256 await together(release.samples, async ([key, etag]) => {
257 const response = await fetchImpl(new URL(key.replace(/^web-assets/, ""), release.origin), { signal: AbortSignal.timeout(30_000) });
258 if (!response.ok || /text\/html/i.test(response.headers.get("content-type") ?? "") || checksum(Buffer.from(await response.arrayBuffer())) !== etag)
259 throw new Error(`Web cutover asset check failed: ${key}`);
260 });
261}
262
263/** Wrangler finishing does not mean every edge is already serving the new version. */
264export async function verifyWebAssets(
265 release,
266 fetchImpl = fetch,
267 { retryWaits = [1000, 2000, 4000, 8000, 15000, 30000], wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), onLine = () => {} } = {},
268) {
269 const deadline = AbortSignal.timeout(120_000);
270 const boundedFetch = (url, options = {}) => fetchImpl(url, { ...options, signal: options.signal ? AbortSignal.any([deadline, options.signal]) : deadline });
271 for (let attempt = 0; ; attempt++) {
272 try {
273 await verifyWebAssetsOnce(release, boundedFetch);
274 return;
275 } catch (error) {
276 if (attempt >= retryWaits.length || deadline.aborted) throw error;
277 onLine(`Waiting for web cutover (${retryWaits[attempt] / 1000}s): ${error.message}`);
278 await wait(retryWaits[attempt]);
279 deadline.throwIfAborted();
280 }
281 }
282}
283
284if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
285 const release = await publishWebAssets(process.cwd());
286 if (process.argv.includes("--deploy")) {
287 const deployed = await wrangler(["deploy"], { cwd: process.cwd(), onLine: console.log });
288 if (deployed.code !== 0) throw new Error("Web deploy failed");
289 await verifyWebAssets(release, fetch, { onLine: console.log });
290 console.log("Web cutover verified: current and previous build assets respond");
291 }
292}