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