pr_01m47d15m3e54sn21z27rpy5n9/services/deployments/src/cloudflare.ts
Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Deployments: a preview for every pull request, production on g1t.page | 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 | const API = "https://api.cloudflare.com/client/v4"; | |
| 13 | ||
| 14 | export type Manifest = Record<string, { hash: string; size: number }>; | |
| 15 | ||
| 16 | /** A module of a Worker, as the sandbox sends it. */ | |
| 17 | export type Module = { name: string; contentBase64: string; contentType: string }; | |
| 18 | ||
| 19 | /** What the sandbox built: the Worker's code and settings. */ | |
| 20 | export type BuiltWorker = { | |
| 21 | mainModule?: string; | |
| 22 | modules?: Module[]; | |
| 23 | compatibilityDate?: string; | |
| 24 | compatibilityFlags?: string[]; | |
| 25 | vars?: Record<string, unknown>; | |
| 26 | assetsBinding?: string | null; | |
| 27 | htmlHandling?: string | null; | |
| 28 | notFoundHandling?: string | null; | |
| 29 | _headers?: string; | |
| 30 | _redirects?: string; | |
| 31 | }; | |
| 32 | ||
| 33 | /** Serves the site's files, for an app that brings no code of its own. */ | |
| 34 | const ASSETS_ONLY = `export default { fetch(request, env) { return env.ASSETS.fetch(request); } };\n`; | |
| 35 | ||
| 36 | const HTML_HANDLING = ["auto-trailing-slash", "force-trailing-slash", "drop-trailing-slash", "none"]; | |
| 37 | const NOT_FOUND_HANDLING = ["single-page-application", "404-page", "none"]; | |
| 38 | ||
| 39 | export class Cloudflare { | |
| 40 | constructor( | |
| 41 | private readonly token: string, | |
| 42 | private readonly account: string, | |
| 43 | readonly namespace: string, | |
| 44 | ) {} | |
| 45 | ||
| 46 | private async call<T>(method: string, path: string, body?: BodyInit, contentType?: string): Promise<T> { | |
| 47 | const headers: Record<string, string> = { authorization: `Bearer ${this.token}` }; | |
| 48 | if (contentType) headers["content-type"] = contentType; | |
| 49 | const response = await fetch(`${API}${path}`, { method, headers, body }); | |
| 50 | const answer = (await response.json().catch(() => null)) as { | |
| 51 | success?: boolean; | |
| 52 | result?: T; | |
| 53 | errors?: { code: number; message: string }[]; | |
| 54 | } | null; | |
| 55 | if (!response.ok || !answer?.success) { | |
| 56 | const why = answer?.errors?.map((error) => `${error.message} (${error.code})`).join("; "); | |
| 57 | throw new Error(`Cloudflare answered ${response.status}: ${why || "no reason given"}`); | |
| 58 | } | |
| 59 | return answer.result as T; | |
| 60 | } | |
| 61 | ||
| 62 | private scriptPath(script: string): string { | |
| 63 | return `/accounts/${this.account}/workers/dispatch/namespaces/${this.namespace}/scripts/${encodeURIComponent(script)}`; | |
| 64 | } | |
| 65 | ||
| 66 | /** Where a sandbox sends the files an upload session asks for. */ | |
| 67 | get uploadUrl(): string { | |
| 68 | return `${API}/accounts/${this.account}/workers/assets/upload?base64=true`; | |
| 69 | } | |
| 70 | ||
| 71 | /** | |
| 72 | * Opens an upload of exactly these files. Cloudflare answers with a key | |
| 73 | * that can upload only them, and the files it does not already have, in | |
| 74 | * buckets; with no buckets, the key itself completes the upload. | |
| 75 | */ | |
| 76 | async openUpload(script: string, manifest: Manifest): Promise<{ jwt: string; buckets: string[][] }> { | |
| 77 | const result = await this.call<{ jwt: string; buckets?: string[][] }>( | |
| 78 | "POST", | |
| 79 | `${this.scriptPath(script)}/assets-upload-session`, | |
| 80 | JSON.stringify({ manifest }), | |
| 81 | "application/json", | |
| 82 | ); | |
| 83 | return { jwt: result.jwt, buckets: result.buckets ?? [] }; | |
| 84 | } | |
| 85 | ||
| 86 | /** Puts an app in the namespace, replacing what was there. */ | |
| 87 | async putScript( | |
| 88 | script: string, | |
| 89 | worker: BuiltWorker, | |
| 90 | completionJwt: string | null, | |
| 91 | tags: string[], | |
| Secrets and variables: one list, rows per environment, for workflows and deployments | 92 | /** The repository's entries for running apps, over the project's own `vars`. */ |
| 93 | runtime: { secrets: Record<string, string>; variables: Record<string, string> } = { secrets: {}, variables: {} }, | |
| Deployments: a preview for every pull request, production on g1t.page | 94 | ): Promise<void> { |
| 95 | const form = new FormData(); | |
| 96 | const modules = worker.modules?.length ? worker.modules : null; | |
| 97 | const assetsBinding = worker.assetsBinding || "ASSETS"; | |
| Secrets and variables: one list, rows per environment, for workflows and deployments | 98 | const vars: Record<string, unknown> = { ...(worker.vars ?? {}), ...runtime.variables }; |
| 99 | for (const name of Object.keys(runtime.secrets)) delete vars[name]; | |
| 100 | const bindings: object[] = Object.entries(vars).map(([name, value]) => | |
| Deployments: a preview for every pull request, production on g1t.page | 101 | typeof value === "string" |
| 102 | ? { type: "plain_text", name, text: value } | |
| 103 | : { type: "json", name, json: value }, | |
| 104 | ); | |
| Secrets and variables: one list, rows per environment, for workflows and deployments | 105 | for (const [name, text] of Object.entries(runtime.secrets)) bindings.push({ type: "secret_text", name, text }); |
| Deployments: a preview for every pull request, production on g1t.page | 106 | if (completionJwt) bindings.push({ type: "assets", name: assetsBinding }); |
| 107 | const assetsConfig: Record<string, string> = {}; | |
| 108 | if (worker.htmlHandling && HTML_HANDLING.includes(worker.htmlHandling)) { | |
| 109 | assetsConfig.html_handling = worker.htmlHandling; | |
| 110 | } | |
| 111 | if (worker.notFoundHandling && NOT_FOUND_HANDLING.includes(worker.notFoundHandling)) { | |
| 112 | assetsConfig.not_found_handling = worker.notFoundHandling; | |
| 113 | } | |
| 114 | if (worker._headers) assetsConfig._headers = worker._headers; | |
| 115 | if (worker._redirects) assetsConfig._redirects = worker._redirects; | |
| 116 | const mainModule = modules ? worker.mainModule ?? modules[0].name : "index.js"; | |
| 117 | form.append( | |
| 118 | "metadata", | |
| 119 | JSON.stringify({ | |
| 120 | main_module: mainModule, | |
| 121 | compatibility_date: worker.compatibilityDate ?? "2026-09-26", | |
| 122 | compatibility_flags: worker.compatibilityFlags ?? [], | |
| 123 | bindings, | |
| 124 | tags, | |
| 125 | ...(completionJwt ? { assets: { jwt: completionJwt, config: assetsConfig } } : {}), | |
| 126 | }), | |
| 127 | ); | |
| 128 | if (modules) { | |
| 129 | for (const module of modules) { | |
| 130 | const bytes = Uint8Array.from(atob(module.contentBase64), (c) => c.charCodeAt(0)); | |
| 131 | form.append(module.name, new File([bytes], module.name, { type: module.contentType })); | |
| 132 | } | |
| 133 | } else { | |
| 134 | if (!completionJwt) throw new Error("The build produced neither code nor files to serve."); | |
| 135 | form.append( | |
| 136 | "index.js", | |
| 137 | new File([ASSETS_ONLY], "index.js", { type: "application/javascript+module" }), | |
| 138 | ); | |
| 139 | } | |
| 140 | await this.call("PUT", this.scriptPath(script), form); | |
| 141 | } | |
| 142 | ||
| 143 | /** Takes an app down. Already gone is fine. */ | |
| 144 | async deleteScript(script: string): Promise<void> { | |
| 145 | try { | |
| 146 | await this.call("DELETE", `${this.scriptPath(script)}?force=true`); | |
| 147 | } catch (error) { | |
| 148 | if (!/404|not found|10007/i.test(String(error))) throw error; | |
| 149 | } | |
| 150 | } | |
| 151 | ||
| 152 | /** | |
| 153 | * Requests and CPU time per app over a period, from Workers analytics. | |
| 154 | * Apps with no traffic are absent. | |
| 155 | */ | |
| 156 | async usage( | |
| 157 | scripts: string[], | |
| 158 | since: string, | |
| 159 | until: string, | |
| 160 | ): Promise<Map<string, { requests: number; cpuMs: number }>> { | |
| 161 | const totals = new Map<string, { requests: number; cpuMs: number }>(); | |
| 162 | if (scripts.length === 0) return totals; | |
| 163 | const query = (withCpu: boolean) => `query ($account: string!, $since: Time!, $until: Time!, $scripts: [string!]) { | |
| 164 | viewer { accounts(filter: { accountTag: $account }) { | |
| 165 | workersInvocationsAdaptive(limit: 10000, filter: { datetime_geq: $since, datetime_lt: $until, scriptName_in: $scripts }) { | |
| 166 | sum { requests${withCpu ? " cpuTimeUs" : ""} } | |
| 167 | dimensions { scriptName } | |
| 168 | } | |
| 169 | } } | |
| 170 | }`; | |
| 171 | type Row = { sum: { requests: number; cpuTimeUs?: number }; dimensions: { scriptName: string } }; | |
| 172 | const ask = async (withCpu: boolean) => { | |
| 173 | const response = await fetch(`${API}/graphql`, { | |
| 174 | method: "POST", | |
| 175 | headers: { authorization: `Bearer ${this.token}`, "content-type": "application/json" }, | |
| 176 | body: JSON.stringify({ | |
| 177 | query: query(withCpu), | |
| 178 | variables: { account: this.account, since, until, scripts }, | |
| 179 | }), | |
| 180 | }); | |
| 181 | return (await response.json()) as { | |
| 182 | data?: { viewer: { accounts: { workersInvocationsAdaptive: Row[] }[] } }; | |
| 183 | errors?: { message: string }[] | null; | |
| 184 | }; | |
| 185 | }; | |
| 186 | let answer = await ask(true); | |
| 187 | // CPU time is counted where analytics offers it; requests always. | |
| 188 | if (answer.errors?.length) answer = await ask(false); | |
| 189 | if (answer.errors?.length || !answer.data) { | |
| 190 | throw new Error(`Workers analytics refused: ${answer.errors?.map((e) => e.message).join("; ")}`); | |
| 191 | } | |
| 192 | for (const row of answer.data.viewer.accounts[0]?.workersInvocationsAdaptive ?? []) { | |
| 193 | const seen = totals.get(row.dimensions.scriptName) ?? { requests: 0, cpuMs: 0 }; | |
| 194 | seen.requests += row.sum.requests; | |
| 195 | seen.cpuMs += Math.ceil((row.sum.cpuTimeUs ?? 0) / 1000); | |
| 196 | totals.set(row.dimensions.scriptName, seen); | |
| 197 | } | |
| 198 | return totals; | |
| 199 | } | |
| 200 | } |