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.

440 linesCodeBlameRaw
1/**
2 * Cloudflare's API, behind the calls deployments need: open an upload of
3 * an app's files, put the app in the dispatch namespace, take it down, and
4 * count what each app used.
5 *
6 * The token is the service's own, scoped to Workers scripts and analytics on
7 * g1t's account. It never leaves this Worker: a sandbox only ever gets an
8 * upload session's key, which can upload one manifest's files and nothing
9 * else.
10 */
11
12import { TAIL_CONSUMERS, observabilityMetadata } from "./telemetry";
13import { APP_CLASS, APP_WORKER, REGISTRY_HOST, applicationName, applicationRequest, pushLoginRequest, rolloutRequest } from "./containers";
14import { redirectScript } from "./redirect";
15
16const API = "https://api.cloudflare.com/client/v4";
17
18export type Manifest = Record<string, { hash: string; size: number }>;
19
20/** A module of a Worker, as the sandbox sends it. */
21export type Module = { name: string; contentBase64: string; contentType: string };
22
23/** What the sandbox built: the Worker's code and settings. */
24export type BuiltWorker = {
25 mainModule?: string;
26 modules?: Module[];
27 compatibilityDate?: string;
28 compatibilityFlags?: string[];
29 vars?: Record<string, unknown>;
30 assetsBinding?: string | null;
31 htmlHandling?: string | null;
32 notFoundHandling?: string | null;
33 _headers?: string;
34 _redirects?: string;
35};
36
37/** Serves the site's files, for an app that brings no code of its own. */
38const ASSETS_ONLY = `export default { fetch(request, env) { return env.ASSETS.fetch(request); } };\n`;
39
40/** What an app answers between being taken down and being gone. */
41const PAUSED = `export default {
42 fetch() {
43 return new Response("<!doctype html><meta charset=utf-8><meta name=robots content=noindex><title>Paused</title><body style='font:16px system-ui;background:#121214;color:#ececf1;display:grid;place-items:center;min-height:100vh;margin:0;padding:0 16px'><main style='max-width:32rem'><h1>This app is paused</h1><p style='color:#9a9aa6'>The workspace it belongs to reached its usage limit on g1t. It comes back by itself once the workspace is under it again.</p></main>", { status: 402, headers: { "content-type": "text/html; charset=utf-8", "x-robots-tag": "noindex", "cache-control": "no-store" } });
44 },
45};`;
46
47const TAKEN_DOWN = `export default {
48 fetch() {
49 return new Response("<!doctype html><meta charset=utf-8><meta name=robots content=noindex><title>Not up</title><body style='font:16px system-ui;background:#121214;color:#ececf1;display:grid;place-items:center;min-height:100vh;margin:0'><main><h1>This app is not up</h1><p style='color:#9a9aa6'>It was taken down. Deploying it again brings it back.</p></main>", { status: 404, headers: { "content-type": "text/html; charset=utf-8", "x-robots-tag": "noindex", "cache-control": "no-store" } });
50 },
51};
52`;
53
54/** The manifests the registry is asked for: an image or an index of them, in either format. */
55const MANIFEST_TYPES =
56 "application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json";
57
58/**
59 * A request to Cloudflare's registry with a login of g1t's own: sent with
60 * it as Basic, as wrangler does, and again with a token for `scope` when
61 * the registry answers with the token exchange registries use
62 * (`WWW-Authenticate: Bearer`).
63 */
64async function registryFetch(login: { username: string; password: string }, path: string, method: string, scope: string, body?: string): Promise<Response> {
65 const basic = `Basic ${btoa(`${login.username}:${login.password}`)}`;
66 const url = `https://${REGISTRY_HOST}${path}`;
67 const headers = (authorization: string): Record<string, string> => ({
68 authorization,
69 accept: MANIFEST_TYPES,
70 ...(body === undefined ? {} : { "content-type": "application/json" }),
71 });
72 const response = await fetch(url, { method, headers: headers(basic), body });
73 const challenge = response.headers.get("www-authenticate") ?? "";
74 if (response.status !== 401 || !/^bearer /i.test(challenge)) return response;
75 const params = Object.fromEntries([...challenge.matchAll(/(\w+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
76 if (!params.realm) return response;
77 const token = new URL(params.realm);
78 if (params.service) token.searchParams.set("service", params.service);
79 token.searchParams.set("scope", params.scope ?? scope);
80 const answer = (await (await fetch(token, { headers: { authorization: basic } })).json().catch(() => null)) as {
81 token?: string;
82 access_token?: string;
83 } | null;
84 const bearer = answer?.token ?? answer?.access_token;
85 if (!bearer) return response;
86 return fetch(url, { method, headers: headers(`Bearer ${bearer}`), body });
87}
88
89/**
90 * The tags in `repository` (`<account>/<image>`): from the registry's tag
91 * list, else from its catalog with tags, which is what Cloudflare's own
92 * tools read (its names there may lack the account, or start with `/`).
93 */
94async function listTags(login: { username: string; password: string }, repository: string, account: string): Promise<string[]> {
95 const scope = `repository:${repository}:pull`;
96 const listed = await registryFetch(login, `/v2/${repository}/tags/list`, "GET", scope);
97 if (listed.ok) {
98 const answer = (await listed.json().catch(() => null)) as { tags?: string[] | null } | null;
99 return answer?.tags ?? [];
100 }
101 if (listed.status === 404) return [];
102 const catalog = await registryFetch(login, "/v2/_catalog?tags=true", "GET", "registry:catalog:*");
103 if (!catalog.ok) throw new Error(`The registry would not list ${repository}'s images (${listed.status}, ${catalog.status}).`);
104 const answer = (await catalog.json().catch(() => null)) as { repositories?: Record<string, string[]> } | null;
105 const image = repository.slice(account.length + 1);
106 for (const [name, tags] of Object.entries(answer?.repositories ?? {})) {
107 const bare = name.replace(/^\/+/, "");
108 if (bare === repository || bare === image) return tags ?? [];
109 }
110 return [];
111}
112
113const HTML_HANDLING = ["auto-trailing-slash", "force-trailing-slash", "drop-trailing-slash", "none"];
114const NOT_FOUND_HANDLING = ["single-page-application", "404-page", "none"];
115
116export class Cloudflare {
117 constructor(
118 private readonly token: string,
119 private readonly account: string,
120 readonly namespace: string,
121 /** The Workers Observability destination apps send their traces to (src/telemetry.ts); none, none sent. */
122 private readonly tracesDestination: string | null = null,
123 ) {}
124
125 private async call<T>(method: string, path: string, body?: BodyInit, contentType?: string): Promise<T> {
126 const headers: Record<string, string> = { authorization: `Bearer ${this.token}` };
127 if (contentType) headers["content-type"] = contentType;
128 const response = await fetch(`${API}${path}`, { method, headers, body });
129 const answer = (await response.json().catch(() => null)) as {
130 success?: boolean;
131 result?: T;
132 errors?: { code: number; message: string }[];
133 } | null;
134 if (!response.ok || !answer?.success) {
135 const why = answer?.errors?.map((error) => `${error.message} (${error.code})`).join("; ");
136 throw new Error(`Cloudflare answered ${response.status}: ${why || "no reason given"}`);
137 }
138 return answer.result as T;
139 }
140
141 private scriptPath(script: string): string {
142 return `/accounts/${this.account}/workers/dispatch/namespaces/${this.namespace}/scripts/${encodeURIComponent(script)}`;
143 }
144
145 /** Where a sandbox sends the files an upload session asks for. */
146 get uploadUrl(): string {
147 return `${API}/accounts/${this.account}/workers/assets/upload?base64=true`;
148 }
149
150 /**
151 * Opens an upload of exactly these files. Cloudflare answers with a key
152 * that can upload only them, and the files it does not already have, in
153 * buckets; with no buckets, the key itself completes the upload.
154 */
155 async openUpload(script: string, manifest: Manifest): Promise<{ jwt: string; buckets: string[][] }> {
156 const result = await this.call<{ jwt: string; buckets?: string[][] }>(
157 "POST",
158 `${this.scriptPath(script)}/assets-upload-session`,
159 JSON.stringify({ manifest }),
160 "application/json",
161 );
162 return { jwt: result.jwt, buckets: result.buckets ?? [] };
163 }
164
165 /** Puts an app in the namespace, replacing what was there. */
166 async putScript(
167 script: string,
168 worker: BuiltWorker,
169 completionJwt: string | null,
170 tags: string[],
171 /** The repository's entries for running apps, over the project's own `vars`. */
172 runtime: { secrets: Record<string, string>; variables: Record<string, string> } = { secrets: {}, variables: {} },
173 ): Promise<void> {
174 const form = new FormData();
175 const modules = worker.modules?.length ? worker.modules : null;
176 const assetsBinding = worker.assetsBinding || "ASSETS";
177 const vars: Record<string, unknown> = { ...(worker.vars ?? {}), ...runtime.variables };
178 for (const name of Object.keys(runtime.secrets)) delete vars[name];
179 const bindings: object[] = Object.entries(vars).map(([name, value]) =>
180 typeof value === "string" ? { type: "plain_text", name, text: value } : { type: "json", name, json: value },
181 );
182 for (const [name, text] of Object.entries(runtime.secrets)) bindings.push({ type: "secret_text", name, text });
183 if (completionJwt) bindings.push({ type: "assets", name: assetsBinding });
184 const assetsConfig: Record<string, string> = {};
185 if (worker.htmlHandling && HTML_HANDLING.includes(worker.htmlHandling)) {
186 assetsConfig.html_handling = worker.htmlHandling;
187 }
188 if (worker.notFoundHandling && NOT_FOUND_HANDLING.includes(worker.notFoundHandling)) {
189 assetsConfig.not_found_handling = worker.notFoundHandling;
190 }
191 if (worker._headers) assetsConfig._headers = worker._headers;
192 if (worker._redirects) assetsConfig._redirects = worker._redirects;
193 const mainModule = modules ? (worker.mainModule ?? modules[0].name) : "index.js";
194 form.append(
195 "metadata",
196 JSON.stringify({
197 main_module: mainModule,
198 compatibility_date: worker.compatibilityDate ?? "2026-09-26",
199 compatibility_flags: worker.compatibilityFlags ?? [],
200 bindings,
201 tags,
202 // Each request it answers, with its logs, goes to observability (src/telemetry.ts).
203 tail_consumers: TAIL_CONSUMERS,
204 ...observabilityMetadata(this.tracesDestination),
205 ...(completionJwt ? { assets: { jwt: completionJwt, config: assetsConfig } } : {}),
206 }),
207 );
208 if (modules) {
209 for (const module of modules) {
210 const bytes = Uint8Array.from(atob(module.contentBase64), (c) => c.charCodeAt(0));
211 form.append(module.name, new File([bytes], module.name, { type: module.contentType }));
212 }
213 } else {
214 if (!completionJwt) throw new Error("The build produced neither code nor files to serve.");
215 form.append("index.js", new File([ASSETS_ONLY], "index.js", { type: "application/javascript+module" }));
216 }
217 await this.call("PUT", this.scriptPath(script), form);
218 }
219
220 // ---- Container apps (containers.ts) ---------------------------------
221
222 /**
223 * A login to Cloudflare's registry for this account: push only (never
224 * pull, so a build cannot read another app's image), for minutes.
225 */
226 async registryLogin(permissions: ("push" | "pull")[] = ["push"]): Promise<{ username: string; password: string }> {
227 const body = permissions.includes("pull") ? { permissions, expiration_minutes: 5 } : pushLoginRequest();
228 const result = await this.call<{ username: string; password?: string }>(
229 "POST",
230 `/accounts/${this.account}/containers/registries/${REGISTRY_HOST}/credentials`,
231 JSON.stringify(body),
232 "application/json",
233 );
234 if (!result.password) throw new Error("Cloudflare's registry gave no password.");
235 return { username: result.username, password: result.password };
236 }
237
238 /**
239 * Whether `repository` holds the image `digest`: asked of the registry
240 * itself, with a pull login of g1t's own, through the token exchange
241 * registries use (`WWW-Authenticate: Bearer`).
242 */
243 async hasImage(repository: string, digest: string): Promise<boolean> {
244 const login = await this.registryLogin(["pull"]);
245 const response = await registryFetch(login, `/v2/${repository}/manifests/${digest}`, "HEAD", `repository:${repository}:pull`);
246 return response.ok;
247 }
248
249 /**
250 * Deletes the images in `repository` whose tags are not in `keep`, then
251 * has the registry collect the layers no image uses any more: an app
252 * keeps its running image and the one before it, so the account stays
253 * under Containers' image storage limit (`IMAGE_STORAGE_GB`). With
254 * `keep` empty, every image of the app goes, as when it is taken down.
255 * Returns the tags deleted. Asked with a login of g1t's own that can pull
256 * and push, as `wrangler containers images delete` is.
257 */
258 async pruneImages(repository: string, keep: string[]): Promise<string[]> {
259 const login = await this.registryLogin(["pull", "push"]);
260 const scope = `repository:${repository}:pull,push,delete`;
261 const tags = await listTags(login, repository, this.account);
262 const gone = tags.filter((tag) => !keep.includes(tag));
263 for (const tag of gone) {
264 const response = await registryFetch(login, `/v2/${repository}/manifests/${encodeURIComponent(tag)}`, "DELETE", scope);
265 if (!response.ok && response.status !== 404) throw new Error(`The registry would not delete ${repository}:${tag} (${response.status}).`);
266 }
267 if (gone.length > 0) {
268 const collected = await registryFetch(login, "/v2/gc/layers", "PUT", scope, "{}");
269 if (!collected.ok) console.error("the registry did not collect unused layers", collected.status);
270 }
271 return gone;
272 }
273
274 /** Puts a container app's Worker (`APP_WORKER`, g1t's own) in the namespace, with `metadata`. */
275 async putContainerWorker(script: string, metadata: Record<string, unknown>): Promise<void> {
276 const form = new FormData();
277 form.append("metadata", JSON.stringify({ ...metadata, ...observabilityMetadata(this.tracesDestination) }));
278 form.append("index.js", new File([APP_WORKER], "index.js", { type: "application/javascript+module" }));
279 await this.call("PUT", this.scriptPath(script), form);
280 }
281
282 /**
283 * The Durable Object namespace of a container app's Worker: what its
284 * Container application is attached to. Read from the Worker's own
285 * `APP` binding, which names it; else looked for among the account's
286 * namespaces, by class and script (a namespace there is matched to this
287 * dispatch namespace when Cloudflare says which it is in, which its
288 * documented schema does not promise).
289 */
290 async appNamespace(script: string): Promise<string> {
291 const bindings = await this.call<{ type: string; name: string; namespace_id?: string }[]>("GET", `${this.scriptPath(script)}/bindings`).catch(() => []);
292 const bound = bindings.find((b) => b.type === "durable_object_namespace" && b.name === "APP" && b.namespace_id);
293 if (bound?.namespace_id) return bound.namespace_id;
294 for (let page = 1; page <= 20; page++) {
295 const namespaces = await this.call<{ id: string; class: string; script: string; dispatch_namespace?: string }[]>(
296 "GET",
297 `/accounts/${this.account}/workers/durable_objects/namespaces?per_page=1000&page=${page}`,
298 );
299 const found = namespaces.find(
300 (n) => n.class === APP_CLASS && n.script === script && (n.dispatch_namespace === undefined || n.dispatch_namespace === this.namespace),
301 );
302 if (found) return found.id;
303 if (namespaces.length < 1000) break;
304 }
305 throw new Error(`The app's Durable Object namespace was not found for ${script}.`);
306 }
307
308 /** The Container application named `name`, if there is one. */
309 async containerApplication(name: string): Promise<{ id: string } | null> {
310 const apps = await this.call<{ id: string; name: string }[]>("GET", `/accounts/${this.account}/containers/applications`);
311 return apps.find((app) => app.name === name) ?? null;
312 }
313
314 /**
315 * Runs `image` at `instanceType` for the app `script`: its Container
316 * application is made the first time, and rolled out to after.
317 */
318 async runContainer(script: string, image: string, instanceType: string, description: string): Promise<string> {
319 const name = applicationName(script);
320 const existing = await this.containerApplication(name);
321 if (existing) {
322 await this.call(
323 "POST",
324 `/accounts/${this.account}/containers/applications/${existing.id}/rollouts`,
325 JSON.stringify(rolloutRequest(image, instanceType, description)),
326 "application/json",
327 );
328 return existing.id;
329 }
330 const namespaceId = await this.appNamespace(script);
331 const created = await this.call<{ id: string }>(
332 "POST",
333 `/accounts/${this.account}/containers/applications`,
334 JSON.stringify(applicationRequest(name, image, instanceType, namespaceId)),
335 "application/json",
336 );
337 return created.id;
338 }
339
340 /** Deletes the app's Container application, and its instances with it. Already gone is fine. */
341 async deleteContainer(script: string, id?: string | null): Promise<void> {
342 const found = id ? { id } : await this.containerApplication(applicationName(script)).catch(() => null);
343 if (!found) return;
344 try {
345 await this.call("DELETE", `/accounts/${this.account}/containers/applications/${found.id}`);
346 } catch (error) {
347 if (!/404|not found/i.test(String(error))) throw error;
348 }
349 }
350
351 /** Every app in the namespace, with when it was last changed. */
352 async listScripts(): Promise<{ id: string; modified_on: string }[]> {
353 return this.call<{ id: string; modified_on: string }[]>("GET", `/accounts/${this.account}/workers/dispatch/namespaces/${this.namespace}/scripts`);
354 }
355
356 /**
357 * Takes an app down. Already gone is fine.
358 *
359 * A deleted app can keep answering for a while where Cloudflare still has
360 * it warm. So it is first replaced by a notice that it is down, which
361 * reaches the edge as fast as any deploy, and deleted after.
362 */
363 async deleteScript(script: string): Promise<void> {
364 await this.placeholder(script, TAKEN_DOWN, "taken-down").catch(() => undefined);
365 try {
366 await this.call("DELETE", `${this.scriptPath(script)}?force=true`);
367 } catch (error) {
368 if (!/404|not found|10007/i.test(String(error))) throw error;
369 }
370 }
371
372 /**
373 * Replaces an app with a notice that it is paused: its workspace reached
374 * its limit. The notice costs next to nothing to answer with, and the app
375 * comes back by being deployed again.
376 */
377 async pauseScript(script: string): Promise<void> {
378 await this.placeholder(script, PAUSED, "paused");
379 }
380
381 /**
382 * Replaces an app with a redirect to `targetHost`, path and query kept:
383 * what an app's old address answers after its workspace is renamed.
384 */
385 async redirectScript(script: string, targetHost: string): Promise<void> {
386 await this.placeholder(script, redirectScript(targetHost), "redirect");
387 }
388
389 private async placeholder(script: string, code: string, tag: string): Promise<void> {
390 const form = new FormData();
391 form.append("metadata", JSON.stringify({ main_module: "index.js", compatibility_date: "2026-09-26", bindings: [], tags: [tag] }));
392 form.append("index.js", new File([code], "index.js", { type: "application/javascript+module" }));
393 await this.call("PUT", this.scriptPath(script), form);
394 }
395
396 /**
397 * Requests and CPU time per app over a period, from Workers analytics.
398 * Apps with no traffic are absent.
399 */
400 async usage(scripts: string[], since: string, until: string): Promise<Map<string, { requests: number; cpuMs: number }>> {
401 const totals = new Map<string, { requests: number; cpuMs: number }>();
402 if (scripts.length === 0) return totals;
403 const query = (withCpu: boolean) => `query ($account: string!, $since: Time!, $until: Time!, $scripts: [string!]) {
404 viewer { accounts(filter: { accountTag: $account }) {
405 workersInvocationsAdaptive(limit: 10000, filter: { datetime_geq: $since, datetime_lt: $until, scriptName_in: $scripts }) {
406 sum { requests${withCpu ? " cpuTimeUs" : ""} }
407 dimensions { scriptName }
408 }
409 } }
410 }`;
411 type Row = { sum: { requests: number; cpuTimeUs?: number }; dimensions: { scriptName: string } };
412 const ask = async (withCpu: boolean) => {
413 const response = await fetch(`${API}/graphql`, {
414 method: "POST",
415 headers: { authorization: `Bearer ${this.token}`, "content-type": "application/json" },
416 body: JSON.stringify({
417 query: query(withCpu),
418 variables: { account: this.account, since, until, scripts },
419 }),
420 });
421 return (await response.json()) as {
422 data?: { viewer: { accounts: { workersInvocationsAdaptive: Row[] }[] } };
423 errors?: { message: string }[] | null;
424 };
425 };
426 let answer = await ask(true);
427 // CPU time is counted where analytics offers it; requests always.
428 if (answer.errors?.length) answer = await ask(false);
429 if (answer.errors?.length || !answer.data) {
430 throw new Error(`Workers analytics refused: ${answer.errors?.map((e) => e.message).join("; ")}`);
431 }
432 for (const row of answer.data.viewer.accounts[0]?.workersInvocationsAdaptive ?? []) {
433 const seen = totals.get(row.dimensions.scriptName) ?? { requests: 0, cpuMs: 0 };
434 seen.requests += row.sum.requests;
435 seen.cpuMs += Math.ceil((row.sum.cpuTimeUs ?? 0) / 1000);
436 totals.set(row.dimensions.scriptName, seen);
437 }
438 return totals;
439 }
440}