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.
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 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 | ||
| Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's. | 12 | import { APP_CLASS, APP_WORKER, REGISTRY_HOST, applicationName, applicationRequest, pushLoginRequest, rolloutRequest } from "./containers"; |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 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 | ||
| Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's. | 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 | ||
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.