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