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.

280 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
70async 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. */
86export 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
111export 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. */
142async 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
154export 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
173export 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. */
239async 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. */
252export 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
272if (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}