Skip to content
376 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
54const HTML_HANDLING =["auto-trailing-slash", "force-trailing-slash", "drop-trailing-slash", "none"];
55const NOT_FOUND_HANDLING = ["single-page-application", "404-page", "none"];
56
57export class Cloudflare {
58 constructor(
59 private readonly token: string,
60 private readonly account: string,
61 readonly namespace: string,
62 /** The Workers Observability destination apps send their traces to (src/telemetry.ts); none, none sent. */
63 private readonly tracesDestination: string | null = null,
64 ) {}
65
66 private async call<T>(method: string, path: string, body?: BodyInit, contentType?: string): Promise<T> {
67 const headers: Record<string, string> = { authorization: `Bearer ${this.token}` };
68 if (contentType) headers["content-type"] = contentType;
69 const response = await fetch(`${API}${path}`, { method, headers, body });
70 const answer = (await response.json().catch(() => null)) as {
71 success?: boolean;
72 result?: T;
73 errors?: { code: number; message: string }[];
74 } | null;
75 if (!response.ok || !answer?.success) {
76 const why = answer?.errors?.map((error) => `${error.message} (${error.code})`).join("; ");
77 throw new Error(`Cloudflare answered ${response.status}: ${why || "no reason given"}`);
78 }
79 return answer.result as T;
80 }
81
82 private scriptPath(script: string): string {
83 return `/accounts/${this.account}/workers/dispatch/namespaces/${this.namespace}/scripts/${encodeURIComponent(script)}`;
84 }
85
86 /** Where a sandbox sends the files an upload session asks for. */
87 get uploadUrl(): string {
88 return `${API}/accounts/${this.account}/workers/assets/upload?base64=true`;
89 }
90
91 /**
92 * Opens an upload of exactly these files. Cloudflare answers with a key
93 * that can upload only them, and the files it does not already have, in
94 * buckets; with no buckets, the key itself completes the upload.
95 */
96 async openUpload(script: string, manifest: Manifest): Promise<{ jwt: string; buckets: string[][] }> {
97 const result = await this.call<{ jwt: string; buckets?: string[][] }>(
98 "POST",
99 `${this.scriptPath(script)}/assets-upload-session`,
100 JSON.stringify({ manifest }),
101 "application/json",
102 );
103 return { jwt: result.jwt, buckets: result.buckets ?? [] };
104 }
105
106 /** Puts an app in the namespace, replacing what was there. */
107 async putScript(
108 script: string,
109 worker: BuiltWorker,
110 completionJwt: string | null,
111 tags: string[],
112 /** The repository's entries for running apps, over the project's own `vars`. */
113 runtime: { secrets: Record<string, string>; variables: Record<string, string> } = { secrets: {}, variables: {} },
114 ): Promise<void> {
115 const form = new FormData();
116 const modules = worker.modules?.length ? worker.modules : null;
117 const assetsBinding = worker.assetsBinding || "ASSETS";
118 const vars: Record<string, unknown> = { ...(worker.vars ?? {}), ...runtime.variables };
119 for (const name of Object.keys(runtime.secrets)) delete vars[name];
120 const bindings: object[] = Object.entries(vars).map(([name, value]) =>
121 typeof value === "string"
122 ? { type: "plain_text", name, text: value }
123 : { type: "json", name, json: value },
124 );
125 for (const [name, text] of Object.entries(runtime.secrets)) bindings.push({ type: "secret_text", name, text });
126 if (completionJwt) bindings.push({ type: "assets", name: assetsBinding });
127 const assetsConfig: Record<string, string> = {};
128 if (worker.htmlHandling && HTML_HANDLING.includes(worker.htmlHandling)) {
129 assetsConfig.html_handling = worker.htmlHandling;
130 }
131 if (worker.notFoundHandling && NOT_FOUND_HANDLING.includes(worker.notFoundHandling)) {
132 assetsConfig.not_found_handling = worker.notFoundHandling;
133 }
134 if (worker._headers) assetsConfig._headers = worker._headers;
135 if (worker._redirects) assetsConfig._redirects = worker._redirects;
136 const mainModule = modules ? worker.mainModule ?? modules[0].name : "index.js";
137 form.append(
138 "metadata",
139 JSON.stringify({
140 main_module: mainModule,
141 compatibility_date: worker.compatibilityDate ?? "2026-09-26",
142 compatibility_flags: worker.compatibilityFlags ?? [],
143 bindings,
144 tags,
145 // Each request it answers, with its logs, goes to observability (src/telemetry.ts).
146 tail_consumers: TAIL_CONSUMERS,
147 ...observabilityMetadata(this.tracesDestination),
148 ...(completionJwt ? { assets: { jwt: completionJwt, config: assetsConfig } } : {}),
149 }),
150 );
151 if (modules) {
152 for (const module of modules) {
153 const bytes = Uint8Array.from(atob(module.contentBase64), (c) => c.charCodeAt(0));
154 form.append(module.name, new File([bytes], module.name, { type: module.contentType }));
155 }
156 } else {
157 if (!completionJwt) throw new Error("The build produced neither code nor files to serve.");
158 form.append(
159 "index.js",
160 new File([ASSETS_ONLY], "index.js", { type: "application/javascript+module" }),
161 );
162 }
163 await this.call("PUT", this.scriptPath(script), form);
164 }
165
166 // ---- Container apps (containers.ts) ---------------------------------
167
168 /**
169 * A login to Cloudflare's registry for this account: push only (never
170 * pull, so a build cannot read another app's image), for minutes.
171 */
172 async registryLogin(permissions: ("push" | "pull")[] = ["push"]): Promise<{ username: string; password: string }> {
173 const body = permissions.includes("pull") ? { permissions, expiration_minutes: 5 } : pushLoginRequest();
174 const result = await this.call<{ username: string; password?: string }>(
175 "POST",
176 `/accounts/${this.account}/containers/registries/${REGISTRY_HOST}/credentials`,
177 JSON.stringify(body),
178 "application/json",
179 );
180 if (!result.password) throw new Error("Cloudflare's registry gave no password.");
181 return { username: result.username, password: result.password };
182 }
183
184 /**
185 * Whether `repository` holds the image `digest`: asked of the registry
186 * itself, with a pull login of g1t's own, through the token exchange
187 * registries use (`WWW-Authenticate: Bearer`).
188 */
189 async hasImage(repository: string, digest: string): Promise<boolean> {
190 const login = await this.registryLogin(["pull"]);
191 const basic = `Basic ${btoa(`${login.username}:${login.password}`)}`;
192 const url = `https://${REGISTRY_HOST}/v2/${repository}/manifests/${digest}`;
193 const accept = "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";
194 let response = await fetch(url, { method: "HEAD", headers: { authorization: basic, accept } });
195 const challenge = response.headers.get("www-authenticate") ?? "";
196 if (response.status === 401 && /^bearer /i.test(challenge)) {
197 const params = Object.fromEntries([...challenge.matchAll(/(\w+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
198 const token = new URL(params.realm);
199 if (params.service) token.searchParams.set("service", params.service);
200 token.searchParams.set("scope", params.scope ?? `repository:${repository}:pull`);
201 const answer = (await (await fetch(token, { headers: { authorization: basic } })).json().catch(() => null)) as {
202 token?: string;
203 access_token?: string;
204 } | null;
205 const bearer = answer?.token ?? answer?.access_token;
206 if (!bearer) return false;
207 response = await fetch(url, { method: "HEAD", headers: { authorization: `Bearer ${bearer}`, accept } });
208 }
209 return response.ok;
210 }
211
212 /** Puts a container app's Worker (`APP_WORKER`, g1t's own) in the namespace, with `metadata`. */
213 async putContainerWorker(script: string, metadata: Record<string, unknown>): Promise<void> {
214 const form = new FormData();
215 form.append("metadata", JSON.stringify({ ...metadata, ...observabilityMetadata(this.tracesDestination) }));
216 form.append("index.js", new File([APP_WORKER], "index.js", { type: "application/javascript+module" }));
217 await this.call("PUT", this.scriptPath(script), form);
218 }
219
220 /** The Durable Object namespace of a container app's Worker: what its Container application is attached to. */
221 async appNamespace(script: string): Promise<string> {
222 for (let page = 1; page <= 20; page++) {
223 const namespaces = await this.call<{ id: string; class: string; script: string; dispatch_namespace?: string }[]>(
224 "GET",
225 `/accounts/${this.account}/workers/durable_objects/namespaces?per_page=1000&page=${page}`,
226 );
227 const found = namespaces.find((n) => n.class === APP_CLASS && n.script === script && n.dispatch_namespace === this.namespace);
228 if (found) return found.id;
229 if (namespaces.length < 1000) break;
230 }
231 throw new Error(`The app's Durable Object namespace was not found for ${script}.`);
232 }
233
234 /** The Container application named `name`, if there is one. */
235 async containerApplication(name: string): Promise<{ id: string } | null> {
236 const apps = await this.call<{ id: string; name: string }[]>("GET", `/accounts/${this.account}/containers/applications`);
237 return apps.find((app) => app.name === name) ?? null;
238 }
239
240 /**
241 * Runs `image` at `instanceType` for the app `script`: its Container
242 * application is made the first time, and rolled out to after.
243 */
244 async runContainer(script: string, image: string, instanceType: string, description: string): Promise<string> {
245 const name = applicationName(script);
246 const existing = await this.containerApplication(name);
247 if (existing) {
248 await this.call(
249 "POST",
250 `/accounts/${this.account}/containers/applications/${existing.id}/rollouts`,
251 JSON.stringify(rolloutRequest(image, instanceType, description)),
252 "application/json",
253 );
254 return existing.id;
255 }
256 const namespaceId = await this.appNamespace(script);
257 const created = await this.call<{ id: string }>(
258 "POST",
259 `/accounts/${this.account}/containers/applications`,
260 JSON.stringify(applicationRequest(name, image, instanceType, namespaceId)),
261 "application/json",
262 );
263 return created.id;
264 }
265
266 /** Deletes the app's Container application, and its instances with it. Already gone is fine. */
267 async deleteContainer(script: string, id?: string | null): Promise<void> {
268 const found = id ? { id } : await this.containerApplication(applicationName(script)).catch(() => null);
269 if (!found) return;
270 try {
271 await this.call("DELETE", `/accounts/${this.account}/containers/applications/${found.id}`);
272 } catch (error) {
273 if (!/404|not found/i.test(String(error))) throw error;
274 }
275 }
276
277 /** Every app in the namespace, with when it was last changed. */
278 async listScripts(): Promise<{ id: string; modified_on: string }[]> {
279 return this.call<{ id: string; modified_on: string }[]>(
280 "GET",
281 `/accounts/${this.account}/workers/dispatch/namespaces/${this.namespace}/scripts`,
282 );
283 }
284
285 /**
286 * Takes an app down. Already gone is fine.
287 *
288 * A deleted app can keep answering for a while where Cloudflare still has
289 * it warm. So it is first replaced by a notice that it is down, which
290 * reaches the edge as fast as any deploy, and deleted after.
291 */
292 async deleteScript(script: string): Promise<void> {
293 await this.placeholder(script, TAKEN_DOWN, "taken-down").catch(() => undefined);
294 try {
295 await this.call("DELETE", `${this.scriptPath(script)}?force=true`);
296 } catch (error) {
297 if (!/404|not found|10007/i.test(String(error))) throw error;
298 }
299 }
300
301 /**
302 * Replaces an app with a notice that it is paused: its workspace reached
303 * its limit. The notice costs next to nothing to answer with, and the app
304 * comes back by being deployed again.
305 */
306 async pauseScript(script: string): Promise<void> {
307 await this.placeholder(script, PAUSED, "paused");
308 }
309
310 /**
311 * Replaces an app with a redirect to `targetHost`, path and query kept:
312 * what an app's old address answers after its workspace is renamed.
313 */
314 async redirectScript(script: string, targetHost: string): Promise<void> {
315 await this.placeholder(script, redirectScript(targetHost), "redirect");
316 }
317
318 private async placeholder(script: string, code: string, tag: string): Promise<void> {
319 const form = new FormData();
320 form.append(
321 "metadata",
322 JSON.stringify({ main_module: "index.js", compatibility_date: "2026-09-26", bindings: [], tags: [tag] }),
323 );
324 form.append("index.js", new File([code], "index.js", { type: "application/javascript+module" }));
325 await this.call("PUT", this.scriptPath(script), form);
326 }
327
328 /**
329 * Requests and CPU time per app over a period, from Workers analytics.
330 * Apps with no traffic are absent.
331 */
332 async usage(
333 scripts: string[],
334 since: string,
335 until: string,
336 ): Promise<Map<string, { requests: number; cpuMs: number }>> {
337 const totals = new Map<string, { requests: number; cpuMs: number }>();
338 if (scripts.length === 0) return totals;
339 const query = (withCpu: boolean) => `query ($account: string!, $since: Time!, $until: Time!, $scripts: [string!]) {
340 viewer { accounts(filter: { accountTag: $account }) {
341 workersInvocationsAdaptive(limit: 10000, filter: { datetime_geq: $since, datetime_lt: $until, scriptName_in: $scripts }) {
342 sum { requests${withCpu ? " cpuTimeUs" : ""} }
343 dimensions { scriptName }
344 }
345 } }
346 }`;
347 type Row = { sum: { requests: number; cpuTimeUs?: number }; dimensions: { scriptName: string } };
348 const ask = async (withCpu: boolean) => {
349 const response = await fetch(`${API}/graphql`, {
350 method: "POST",
351 headers: { authorization: `Bearer ${this.token}`, "content-type": "application/json" },
352 body: JSON.stringify({
353 query: query(withCpu),
354 variables: { account: this.account, since, until, scripts },
355 }),
356 });
357 return (await response.json()) as {
358 data?: { viewer: { accounts: { workersInvocationsAdaptive: Row[] }[] } };
359 errors?: { message: string }[] | null;
360 };
361 };
362 let answer = await ask(true);
363 // CPU time is counted where analytics offers it; requests always.
364 if (answer.errors?.length) answer = await ask(false);
365 if (answer.errors?.length || !answer.data) {
366 throw new Error(`Workers analytics refused: ${answer.errors?.map((e) => e.message).join("; ")}`);
367 }
368 for (const row of answer.data.viewer.accounts[0]?.workersInvocationsAdaptive ?? []) {
369 const seen = totals.get(row.dimensions.scriptName) ?? { requests: 0, cpuMs: 0 };
370 seen.requests += row.sum.requests;
371 seen.cpuMs += Math.ceil((row.sum.cpuTimeUs ?? 0) / 1000);
372 totals.set(row.dimensions.scriptName, seen);
373 }
374 return totals;
375 }
376}