pr_01m47d15m3e54sn21z27rpy5n9/services/deployments/src/cloudflare.ts
| 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 { redirectScript } from "./redirect"; |
| 13 | |
| 14 | const API = "https://api.cloudflare.com/client/v4"; |
| 15 | |
| 16 | export type Manifest = Record<string, { hash: string; size: number }>; |
| 17 | |
| 18 | /** A module of a Worker, as the sandbox sends it. */ |
| 19 | export type Module = { name: string; contentBase64: string; contentType: string }; |
| 20 | |
| 21 | /** What the sandbox built: the Worker's code and settings. */ |
| 22 | export type BuiltWorker = { |
| 23 | mainModule?: string; |
| 24 | modules?: Module[]; |
| 25 | compatibilityDate?: string; |
| 26 | compatibilityFlags?: string[]; |
| 27 | vars?: Record<string, unknown>; |
| 28 | assetsBinding?: string | null; |
| 29 | htmlHandling?: string | null; |
| 30 | notFoundHandling?: string | null; |
| 31 | _headers?: string; |
| 32 | _redirects?: string; |
| 33 | }; |
| 34 | |
| 35 | /** Serves the site's files, for an app that brings no code of its own. */ |
| 36 | const ASSETS_ONLY = `export default { fetch(request, env) { return env.ASSETS.fetch(request); } };\n`; |
| 37 | |
| 38 | /** What an app answers between being taken down and being gone. */ |
| 39 | const PAUSED = `export default { |
| 40 | fetch() { |
| 41 | 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" } }); |
| 42 | }, |
| 43 | };`; |
| 44 | |
| 45 | const TAKEN_DOWN = `export default { |
| 46 | fetch() { |
| 47 | 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" } }); |
| 48 | }, |
| 49 | }; |
| 50 | `; |
| 51 | |
| 52 | const HTML_HANDLING =["auto-trailing-slash", "force-trailing-slash", "drop-trailing-slash", "none"]; |
| 53 | const NOT_FOUND_HANDLING = ["single-page-application", "404-page", "none"]; |
| 54 | |
| 55 | export class Cloudflare { |
| 56 | constructor( |
| 57 | private readonly token: string, |
| 58 | private readonly account: string, |
| 59 | readonly namespace: string, |
| 60 | ) {} |
| 61 | |
| 62 | private async call<T>(method: string, path: string, body?: BodyInit, contentType?: string): Promise<T> { |
| 63 | const headers: Record<string, string> = { authorization: `Bearer ${this.token}` }; |
| 64 | if (contentType) headers["content-type"] = contentType; |
| 65 | const response = await fetch(`${API}${path}`, { method, headers, body }); |
| 66 | const answer = (await response.json().catch(() => null)) as { |
| 67 | success?: boolean; |
| 68 | result?: T; |
| 69 | errors?: { code: number; message: string }[]; |
| 70 | } | null; |
| 71 | if (!response.ok || !answer?.success) { |
| 72 | const why = answer?.errors?.map((error) => `${error.message} (${error.code})`).join("; "); |
| 73 | throw new Error(`Cloudflare answered ${response.status}: ${why || "no reason given"}`); |
| 74 | } |
| 75 | return answer.result as T; |
| 76 | } |
| 77 | |
| 78 | private scriptPath(script: string): string { |
| 79 | return `/accounts/${this.account}/workers/dispatch/namespaces/${this.namespace}/scripts/${encodeURIComponent(script)}`; |
| 80 | } |
| 81 | |
| 82 | /** Where a sandbox sends the files an upload session asks for. */ |
| 83 | get uploadUrl(): string { |
| 84 | return `${API}/accounts/${this.account}/workers/assets/upload?base64=true`; |
| 85 | } |
| 86 | |
| 87 | /** |
| 88 | * Opens an upload of exactly these files. Cloudflare answers with a key |
| 89 | * that can upload only them, and the files it does not already have, in |
| 90 | * buckets; with no buckets, the key itself completes the upload. |
| 91 | */ |
| 92 | async openUpload(script: string, manifest: Manifest): Promise<{ jwt: string; buckets: string[][] }> { |
| 93 | const result = await this.call<{ jwt: string; buckets?: string[][] }>( |
| 94 | "POST", |
| 95 | `${this.scriptPath(script)}/assets-upload-session`, |
| 96 | JSON.stringify({ manifest }), |
| 97 | "application/json", |
| 98 | ); |
| 99 | return { jwt: result.jwt, buckets: result.buckets ?? [] }; |
| 100 | } |
| 101 | |
| 102 | /** Puts an app in the namespace, replacing what was there. */ |
| 103 | async putScript( |
| 104 | script: string, |
| 105 | worker: BuiltWorker, |
| 106 | completionJwt: string | null, |
| 107 | tags: string[], |
| 108 | /** The repository's entries for running apps, over the project's own `vars`. */ |
| 109 | runtime: { secrets: Record<string, string>; variables: Record<string, string> } = { secrets: {}, variables: {} }, |
| 110 | ): Promise<void> { |
| 111 | const form = new FormData(); |
| 112 | const modules = worker.modules?.length ? worker.modules : null; |
| 113 | const assetsBinding = worker.assetsBinding || "ASSETS"; |
| 114 | const vars: Record<string, unknown> = { ...(worker.vars ?? {}), ...runtime.variables }; |
| 115 | for (const name of Object.keys(runtime.secrets)) delete vars[name]; |
| 116 | const bindings: object[] = Object.entries(vars).map(([name, value]) => |
| 117 | typeof value === "string" |
| 118 | ? { type: "plain_text", name, text: value } |
| 119 | : { type: "json", name, json: value }, |
| 120 | ); |
| 121 | for (const [name, text] of Object.entries(runtime.secrets)) bindings.push({ type: "secret_text", name, text }); |
| 122 | if (completionJwt) bindings.push({ type: "assets", name: assetsBinding }); |
| 123 | const assetsConfig: Record<string, string> = {}; |
| 124 | if (worker.htmlHandling && HTML_HANDLING.includes(worker.htmlHandling)) { |
| 125 | assetsConfig.html_handling = worker.htmlHandling; |
| 126 | } |
| 127 | if (worker.notFoundHandling && NOT_FOUND_HANDLING.includes(worker.notFoundHandling)) { |
| 128 | assetsConfig.not_found_handling = worker.notFoundHandling; |
| 129 | } |
| 130 | if (worker._headers) assetsConfig._headers = worker._headers; |
| 131 | if (worker._redirects) assetsConfig._redirects = worker._redirects; |
| 132 | const mainModule = modules ? worker.mainModule ?? modules[0].name : "index.js"; |
| 133 | form.append( |
| 134 | "metadata", |
| 135 | JSON.stringify({ |
| 136 | main_module: mainModule, |
| 137 | compatibility_date: worker.compatibilityDate ?? "2026-09-26", |
| 138 | compatibility_flags: worker.compatibilityFlags ?? [], |
| 139 | bindings, |
| 140 | tags, |
| 141 | ...(completionJwt ? { assets: { jwt: completionJwt, config: assetsConfig } } : {}), |
| 142 | }), |
| 143 | ); |
| 144 | if (modules) { |
| 145 | for (const module of modules) { |
| 146 | const bytes = Uint8Array.from(atob(module.contentBase64), (c) => c.charCodeAt(0)); |
| 147 | form.append(module.name, new File([bytes], module.name, { type: module.contentType })); |
| 148 | } |
| 149 | } else { |
| 150 | if (!completionJwt) throw new Error("The build produced neither code nor files to serve."); |
| 151 | form.append( |
| 152 | "index.js", |
| 153 | new File([ASSETS_ONLY], "index.js", { type: "application/javascript+module" }), |
| 154 | ); |
| 155 | } |
| 156 | await this.call("PUT", this.scriptPath(script), form); |
| 157 | } |
| 158 | |
| 159 | /** Every app in the namespace, with when it was last changed. */ |
| 160 | async listScripts(): Promise<{ id: string; modified_on: string }[]> { |
| 161 | return this.call<{ id: string; modified_on: string }[]>( |
| 162 | "GET", |
| 163 | `/accounts/${this.account}/workers/dispatch/namespaces/${this.namespace}/scripts`, |
| 164 | ); |
| 165 | } |
| 166 | |
| 167 | /** |
| 168 | * Takes an app down. Already gone is fine. |
| 169 | * |
| 170 | * A deleted app can keep answering for a while where Cloudflare still has |
| 171 | * it warm. So it is first replaced by a notice that it is down, which |
| 172 | * reaches the edge as fast as any deploy, and deleted after. |
| 173 | */ |
| 174 | async deleteScript(script: string): Promise<void> { |
| 175 | await this.placeholder(script, TAKEN_DOWN, "taken-down").catch(() => undefined); |
| 176 | try { |
| 177 | await this.call("DELETE", `${this.scriptPath(script)}?force=true`); |
| 178 | } catch (error) { |
| 179 | if (!/404|not found|10007/i.test(String(error))) throw error; |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | /** |
| 184 | * Replaces an app with a notice that it is paused: its workspace reached |
| 185 | * its limit. The notice costs next to nothing to answer with, and the app |
| 186 | * comes back by being deployed again. |
| 187 | */ |
| 188 | async pauseScript(script: string): Promise<void> { |
| 189 | await this.placeholder(script, PAUSED, "paused"); |
| 190 | } |
| 191 | |
| 192 | /** |
| 193 | * Replaces an app with a redirect to `targetHost`, path and query kept: |
| 194 | * what an app's old address answers after its workspace is renamed. |
| 195 | */ |
| 196 | async redirectScript(script: string, targetHost: string): Promise<void> { |
| 197 | await this.placeholder(script, redirectScript(targetHost), "redirect"); |
| 198 | } |
| 199 | |
| 200 | private async placeholder(script: string, code: string, tag: string): Promise<void> { |
| 201 | const form = new FormData(); |
| 202 | form.append( |
| 203 | "metadata", |
| 204 | JSON.stringify({ main_module: "index.js", compatibility_date: "2026-09-26", bindings: [], tags: [tag] }), |
| 205 | ); |
| 206 | form.append("index.js", new File([code], "index.js", { type: "application/javascript+module" })); |
| 207 | await this.call("PUT", this.scriptPath(script), form); |
| 208 | } |
| 209 | |
| 210 | /** |
| 211 | * Requests and CPU time per app over a period, from Workers analytics. |
| 212 | * Apps with no traffic are absent. |
| 213 | */ |
| 214 | async usage( |
| 215 | scripts: string[], |
| 216 | since: string, |
| 217 | until: string, |
| 218 | ): Promise<Map<string, { requests: number; cpuMs: number }>> { |
| 219 | const totals = new Map<string, { requests: number; cpuMs: number }>(); |
| 220 | if (scripts.length === 0) return totals; |
| 221 | const query = (withCpu: boolean) => `query ($account: string!, $since: Time!, $until: Time!, $scripts: [string!]) { |
| 222 | viewer { accounts(filter: { accountTag: $account }) { |
| 223 | workersInvocationsAdaptive(limit: 10000, filter: { datetime_geq: $since, datetime_lt: $until, scriptName_in: $scripts }) { |
| 224 | sum { requests${withCpu ? " cpuTimeUs" : ""} } |
| 225 | dimensions { scriptName } |
| 226 | } |
| 227 | } } |
| 228 | }`; |
| 229 | type Row = { sum: { requests: number; cpuTimeUs?: number }; dimensions: { scriptName: string } }; |
| 230 | const ask = async (withCpu: boolean) => { |
| 231 | const response = await fetch(`${API}/graphql`, { |
| 232 | method: "POST", |
| 233 | headers: { authorization: `Bearer ${this.token}`, "content-type": "application/json" }, |
| 234 | body: JSON.stringify({ |
| 235 | query: query(withCpu), |
| 236 | variables: { account: this.account, since, until, scripts }, |
| 237 | }), |
| 238 | }); |
| 239 | return (await response.json()) as { |
| 240 | data?: { viewer: { accounts: { workersInvocationsAdaptive: Row[] }[] } }; |
| 241 | errors?: { message: string }[] | null; |
| 242 | }; |
| 243 | }; |
| 244 | let answer = await ask(true); |
| 245 | // CPU time is counted where analytics offers it; requests always. |
| 246 | if (answer.errors?.length) answer = await ask(false); |
| 247 | if (answer.errors?.length || !answer.data) { |
| 248 | throw new Error(`Workers analytics refused: ${answer.errors?.map((e) => e.message).join("; ")}`); |
| 249 | } |
| 250 | for (const row of answer.data.viewer.accounts[0]?.workersInvocationsAdaptive ?? []) { |
| 251 | const seen = totals.get(row.dimensions.scriptName) ?? { requests: 0, cpuMs: 0 }; |
| 252 | seen.requests += row.sum.requests; |
| 253 | seen.cpuMs += Math.ceil((row.sum.cpuTimeUs ?? 0) / 1000); |
| 254 | totals.set(row.dimensions.scriptName, seen); |
| 255 | } |
| 256 | return totals; |
| 257 | } |
| 258 | } |