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.
| Billing: AI Gateway's analytics are read with the token that can see them, and a gateway that priced nothing is said | 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 | ||
| Apps on g1t.page send Cloudflare's own traces to Observability: a span for each fetch, KV, D1, R2 and Durable Object call, with nothing in their code. Each upload names the Workers Observability destination g1t-traces (TRACES_DESTINATION), which sends OTLP to otel.g1t.sh/cloudflare/v1/traces with the telemetry service's export key; traces only, since logs already arrive through the Tail Worker, and nothing is kept in Cloudflare's own storage. The git guide says that with two-factor authentication on, git takes an access token, not the account password. | 12 | import { TAIL_CONSUMERS, observabilityMetadata } from "./telemetry"; |
| 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. | 13 | import { APP_CLASS, APP_WORKER, REGISTRY_HOST, applicationName, applicationRequest, pushLoginRequest, rolloutRequest } from "./containers"; |
| Billing: AI Gateway's analytics are read with the token that can see them, and a gateway that priced nothing is said | 14 | import { redirectScript } from "./redirect"; |
| 15 | ||
| 16 | const API = "https://api.cloudflare.com/client/v4"; | |
| 17 | ||
| 18 | export type Manifest = Record<string, { hash: string; size: number }>; | |
| 19 | ||
| 20 | /** A module of a Worker, as the sandbox sends it. */ | |
| 21 | export type Module = { name: string; contentBase64: string; contentType: string }; | |
| 22 | ||
| 23 | /** What the sandbox built: the Worker's code and settings. */ | |
| 24 | export 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. */ | |
| 38 | const 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. */ | |
| 41 | const 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 | ||
| 47 | const 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 | ||
| Merge branch 'worktree-agent-a4d0c5ee9c2f877aa' | 54 | /** The manifests the registry is asked for: an image or an index of them, in either format. */ |
| 55 | const MANIFEST_TYPES = | |
| 56 | "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"; | |
| 57 | ||
| 58 | /** | |
| 59 | * A request to Cloudflare's registry with a login of g1t's own: sent with | |
| 60 | * it as Basic, as wrangler does, and again with a token for `scope` when | |
| 61 | * the registry answers with the token exchange registries use | |
| 62 | * (`WWW-Authenticate: Bearer`). | |
| 63 | */ | |
| 64 | async function registryFetch( | |
| 65 | login: { username: string; password: string }, | |
| 66 | path: string, | |
| 67 | method: string, | |
| 68 | scope: string, | |
| 69 | body?: string, | |
| 70 | ): Promise<Response> { | |
| 71 | const basic = `Basic ${btoa(`${login.username}:${login.password}`)}`; | |
| 72 | const url = `https://${REGISTRY_HOST}${path}`; | |
| 73 | const headers = (authorization: string): Record<string, string> => ({ | |
| 74 | authorization, | |
| 75 | accept: MANIFEST_TYPES, | |
| 76 | ...(body === undefined ? {} : { "content-type": "application/json" }), | |
| 77 | }); | |
| 78 | const response = await fetch(url, { method, headers: headers(basic), body }); | |
| 79 | const challenge = response.headers.get("www-authenticate") ?? ""; | |
| 80 | if (response.status !== 401 || !/^bearer /i.test(challenge)) return response; | |
| 81 | const params = Object.fromEntries([...challenge.matchAll(/(\w+)="([^"]*)"/g)].map((m) => [m[1], m[2]])); | |
| 82 | if (!params.realm) return response; | |
| 83 | const token = new URL(params.realm); | |
| 84 | if (params.service) token.searchParams.set("service", params.service); | |
| 85 | token.searchParams.set("scope", params.scope ?? scope); | |
| 86 | const answer = (await (await fetch(token, { headers: { authorization: basic } })).json().catch(() => null)) as { | |
| 87 | token?: string; | |
| 88 | access_token?: string; | |
| 89 | } | null; | |
| 90 | const bearer = answer?.token ?? answer?.access_token; | |
| 91 | if (!bearer) return response; | |
| 92 | return fetch(url, { method, headers: headers(`Bearer ${bearer}`), body }); | |
| 93 | } | |
| 94 | ||
| 95 | /** | |
| 96 | * The tags in `repository` (`<account>/<image>`): from the registry's tag | |
| 97 | * list, else from its catalog with tags, which is what Cloudflare's own | |
| 98 | * tools read (its names there may lack the account, or start with `/`). | |
| 99 | */ | |
| 100 | async function listTags(login: { username: string; password: string }, repository: string, account: string): Promise<string[]> { | |
| 101 | const scope = `repository:${repository}:pull`; | |
| 102 | const listed = await registryFetch(login, `/v2/${repository}/tags/list`, "GET", scope); | |
| 103 | if (listed.ok) { | |
| 104 | const answer = (await listed.json().catch(() => null)) as { tags?: string[] | null } | null; | |
| 105 | return answer?.tags ?? []; | |
| 106 | } | |
| 107 | if (listed.status === 404) return []; | |
| 108 | const catalog = await registryFetch(login, "/v2/_catalog?tags=true", "GET", "registry:catalog:*"); | |
| 109 | if (!catalog.ok) throw new Error(`The registry would not list ${repository}'s images (${listed.status}, ${catalog.status}).`); | |
| 110 | const answer = (await catalog.json().catch(() => null)) as { repositories?: Record<string, string[]> } | null; | |
| 111 | const image = repository.slice(account.length + 1); | |
| 112 | for (const [name, tags] of Object.entries(answer?.repositories ?? {})) { | |
| 113 | const bare = name.replace(/^\/+/, ""); | |
| 114 | if (bare === repository || bare === image) return tags ?? []; | |
| 115 | } | |
| 116 | return []; | |
| 117 | } | |
| 118 | ||
| Billing: AI Gateway's analytics are read with the token that can see them, and a gateway that priced nothing is said | 119 | const HTML_HANDLING =["auto-trailing-slash", "force-trailing-slash", "drop-trailing-slash", "none"]; |
| 120 | const NOT_FOUND_HANDLING = ["single-page-application", "404-page", "none"]; | |
| 121 | ||
| 122 | export class Cloudflare { | |
| 123 | constructor( | |
| 124 | private readonly token: string, | |
| 125 | private readonly account: string, | |
| 126 | readonly namespace: string, | |
| Apps on g1t.page send Cloudflare's own traces to Observability: a span for each fetch, KV, D1, R2 and Durable Object call, with nothing in their code. Each upload names the Workers Observability destination g1t-traces (TRACES_DESTINATION), which sends OTLP to otel.g1t.sh/cloudflare/v1/traces with the telemetry service's export key; traces only, since logs already arrive through the Tail Worker, and nothing is kept in Cloudflare's own storage. The git guide says that with two-factor authentication on, git takes an access token, not the account password. | 127 | /** The Workers Observability destination apps send their traces to (src/telemetry.ts); none, none sent. */ |
| 128 | private readonly tracesDestination: string | null = null, | |
| Billing: AI Gateway's analytics are read with the token that can see them, and a gateway that priced nothing is said | 129 | ) {} |
| 130 | ||
| 131 | private async call<T>(method: string, path: string, body?: BodyInit, contentType?: string): Promise<T> { | |
| 132 | const headers: Record<string, string> = { authorization: `Bearer ${this.token}` }; | |
| 133 | if (contentType) headers["content-type"] = contentType; | |
| 134 | const response = await fetch(`${API}${path}`, { method, headers, body }); | |
| 135 | const answer = (await response.json().catch(() => null)) as { | |
| 136 | success?: boolean; | |
| 137 | result?: T; | |
| 138 | errors?: { code: number; message: string }[]; | |
| 139 | } | null; | |
| 140 | if (!response.ok || !answer?.success) { | |
| 141 | const why = answer?.errors?.map((error) => `${error.message} (${error.code})`).join("; "); | |
| 142 | throw new Error(`Cloudflare answered ${response.status}: ${why || "no reason given"}`); | |
| 143 | } | |
| 144 | return answer.result as T; | |
| 145 | } | |
| 146 | ||
| 147 | private scriptPath(script: string): string { | |
| 148 | return `/accounts/${this.account}/workers/dispatch/namespaces/${this.namespace}/scripts/${encodeURIComponent(script)}`; | |
| 149 | } | |
| 150 | ||
| 151 | /** Where a sandbox sends the files an upload session asks for. */ | |
| 152 | get uploadUrl(): string { | |
| 153 | return `${API}/accounts/${this.account}/workers/assets/upload?base64=true`; | |
| 154 | } | |
| 155 | ||
| 156 | /** | |
| 157 | * Opens an upload of exactly these files. Cloudflare answers with a key | |
| 158 | * that can upload only them, and the files it does not already have, in | |
| 159 | * buckets; with no buckets, the key itself completes the upload. | |
| 160 | */ | |
| 161 | async openUpload(script: string, manifest: Manifest): Promise<{ jwt: string; buckets: string[][] }> { | |
| 162 | const result = await this.call<{ jwt: string; buckets?: string[][] }>( | |
| 163 | "POST", | |
| 164 | `${this.scriptPath(script)}/assets-upload-session`, | |
| 165 | JSON.stringify({ manifest }), | |
| 166 | "application/json", | |
| 167 | ); | |
| 168 | return { jwt: result.jwt, buckets: result.buckets ?? [] }; | |
| 169 | } | |
| 170 | ||
| 171 | /** Puts an app in the namespace, replacing what was there. */ | |
| 172 | async putScript( | |
| 173 | script: string, | |
| 174 | worker: BuiltWorker, | |
| 175 | completionJwt: string | null, | |
| 176 | tags: string[], | |
| 177 | /** The repository's entries for running apps, over the project's own `vars`. */ | |
| 178 | runtime: { secrets: Record<string, string>; variables: Record<string, string> } = { secrets: {}, variables: {} }, | |
| 179 | ): Promise<void> { | |
| 180 | const form = new FormData(); | |
| 181 | const modules = worker.modules?.length ? worker.modules : null; | |
| 182 | const assetsBinding = worker.assetsBinding || "ASSETS"; | |
| 183 | const vars: Record<string, unknown> = { ...(worker.vars ?? {}), ...runtime.variables }; | |
| 184 | for (const name of Object.keys(runtime.secrets)) delete vars[name]; | |
| 185 | const bindings: object[] = Object.entries(vars).map(([name, value]) => | |
| 186 | typeof value === "string" | |
| 187 | ? { type: "plain_text", name, text: value } | |
| 188 | : { type: "json", name, json: value }, | |
| 189 | ); | |
| 190 | for (const [name, text] of Object.entries(runtime.secrets)) bindings.push({ type: "secret_text", name, text }); | |
| 191 | if (completionJwt) bindings.push({ type: "assets", name: assetsBinding }); | |
| 192 | const assetsConfig: Record<string, string> = {}; | |
| 193 | if (worker.htmlHandling && HTML_HANDLING.includes(worker.htmlHandling)) { | |
| 194 | assetsConfig.html_handling = worker.htmlHandling; | |
| 195 | } | |
| 196 | if (worker.notFoundHandling && NOT_FOUND_HANDLING.includes(worker.notFoundHandling)) { | |
| 197 | assetsConfig.not_found_handling = worker.notFoundHandling; | |
| 198 | } | |
| 199 | if (worker._headers) assetsConfig._headers = worker._headers; | |
| 200 | if (worker._redirects) assetsConfig._redirects = worker._redirects; | |
| 201 | const mainModule = modules ? worker.mainModule ?? modules[0].name : "index.js"; | |
| 202 | form.append( | |
| 203 | "metadata", | |
| 204 | JSON.stringify({ | |
| 205 | main_module: mainModule, | |
| 206 | compatibility_date: worker.compatibilityDate ?? "2026-09-26", | |
| 207 | compatibility_flags: worker.compatibilityFlags ?? [], | |
| 208 | bindings, | |
| 209 | tags, | |
| Projects have Observability: their requests, logs, traces, metrics and errors, from apps on g1t.page with nothing to set up, and from software anywhere else over OpenTelemetry. | 210 | // Each request it answers, with its logs, goes to observability (src/telemetry.ts). |
| 211 | tail_consumers: TAIL_CONSUMERS, | |
| Apps on g1t.page send Cloudflare's own traces to Observability: a span for each fetch, KV, D1, R2 and Durable Object call, with nothing in their code. Each upload names the Workers Observability destination g1t-traces (TRACES_DESTINATION), which sends OTLP to otel.g1t.sh/cloudflare/v1/traces with the telemetry service's export key; traces only, since logs already arrive through the Tail Worker, and nothing is kept in Cloudflare's own storage. The git guide says that with two-factor authentication on, git takes an access token, not the account password. | 212 | ...observabilityMetadata(this.tracesDestination), |
| Billing: AI Gateway's analytics are read with the token that can see them, and a gateway that priced nothing is said | 213 | ...(completionJwt ? { assets: { jwt: completionJwt, config: assetsConfig } } : {}), |
| 214 | }), | |
| 215 | ); | |
| 216 | if (modules) { | |
| 217 | for (const module of modules) { | |
| 218 | const bytes = Uint8Array.from(atob(module.contentBase64), (c) => c.charCodeAt(0)); | |
| 219 | form.append(module.name, new File([bytes], module.name, { type: module.contentType })); | |
| 220 | } | |
| 221 | } else { | |
| 222 | if (!completionJwt) throw new Error("The build produced neither code nor files to serve."); | |
| 223 | form.append( | |
| 224 | "index.js", | |
| 225 | new File([ASSETS_ONLY], "index.js", { type: "application/javascript+module" }), | |
| 226 | ); | |
| 227 | } | |
| 228 | await this.call("PUT", this.scriptPath(script), form); | |
| 229 | } | |
| 230 | ||
| 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. | 231 | // ---- Container apps (containers.ts) --------------------------------- |
| 232 | ||
| 233 | /** | |
| 234 | * A login to Cloudflare's registry for this account: push only (never | |
| 235 | * pull, so a build cannot read another app's image), for minutes. | |
| 236 | */ | |
| 237 | async registryLogin(permissions: ("push" | "pull")[] = ["push"]): Promise<{ username: string; password: string }> { | |
| 238 | const body = permissions.includes("pull") ? { permissions, expiration_minutes: 5 } : pushLoginRequest(); | |
| 239 | const result = await this.call<{ username: string; password?: string }>( | |
| 240 | "POST", | |
| 241 | `/accounts/${this.account}/containers/registries/${REGISTRY_HOST}/credentials`, | |
| 242 | JSON.stringify(body), | |
| 243 | "application/json", | |
| 244 | ); | |
| 245 | if (!result.password) throw new Error("Cloudflare's registry gave no password."); | |
| 246 | return { username: result.username, password: result.password }; | |
| 247 | } | |
| 248 | ||
| 249 | /** | |
| 250 | * Whether `repository` holds the image `digest`: asked of the registry | |
| 251 | * itself, with a pull login of g1t's own, through the token exchange | |
| 252 | * registries use (`WWW-Authenticate: Bearer`). | |
| 253 | */ | |
| 254 | async hasImage(repository: string, digest: string): Promise<boolean> { | |
| 255 | const login = await this.registryLogin(["pull"]); | |
| Merge branch 'worktree-agent-a4d0c5ee9c2f877aa' | 256 | const response = await registryFetch(login, `/v2/${repository}/manifests/${digest}`, "HEAD", `repository:${repository}:pull`); |
| 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. | 257 | return response.ok; |
| 258 | } | |
| 259 | ||
| Merge branch 'worktree-agent-a4d0c5ee9c2f877aa' | 260 | /** |
| 261 | * Deletes the images in `repository` whose tags are not in `keep`, then | |
| 262 | * has the registry collect the layers no image uses any more: an app | |
| 263 | * keeps its running image and the one before it, so the account stays | |
| 264 | * under Containers' image storage limit (`IMAGE_STORAGE_GB`). With | |
| 265 | * `keep` empty, every image of the app goes, as when it is taken down. | |
| 266 | * Returns the tags deleted. Asked with a login of g1t's own that can pull | |
| 267 | * and push, as `wrangler containers images delete` is. | |
| 268 | */ | |
| 269 | async pruneImages(repository: string, keep: string[]): Promise<string[]> { | |
| 270 | const login = await this.registryLogin(["pull", "push"]); | |
| 271 | const scope = `repository:${repository}:pull,push,delete`; | |
| 272 | const tags = await listTags(login, repository, this.account); | |
| 273 | const gone = tags.filter((tag) => !keep.includes(tag)); | |
| 274 | for (const tag of gone) { | |
| 275 | const response = await registryFetch(login, `/v2/${repository}/manifests/${encodeURIComponent(tag)}`, "DELETE", scope); | |
| 276 | if (!response.ok && response.status !== 404) throw new Error(`The registry would not delete ${repository}:${tag} (${response.status}).`); | |
| 277 | } | |
| 278 | if (gone.length > 0) { | |
| 279 | const collected = await registryFetch(login, "/v2/gc/layers", "PUT", scope, "{}"); | |
| 280 | if (!collected.ok) console.error("the registry did not collect unused layers", collected.status); | |
| 281 | } | |
| 282 | return gone; | |
| 283 | } | |
| 284 | ||
| 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. | 285 | /** Puts a container app's Worker (`APP_WORKER`, g1t's own) in the namespace, with `metadata`. */ |
| 286 | async putContainerWorker(script: string, metadata: Record<string, unknown>): Promise<void> { | |
| 287 | const form = new FormData(); | |
| Apps on g1t.page send Cloudflare's own traces to Observability: a span for each fetch, KV, D1, R2 and Durable Object call, with nothing in their code. Each upload names the Workers Observability destination g1t-traces (TRACES_DESTINATION), which sends OTLP to otel.g1t.sh/cloudflare/v1/traces with the telemetry service's export key; traces only, since logs already arrive through the Tail Worker, and nothing is kept in Cloudflare's own storage. The git guide says that with two-factor authentication on, git takes an access token, not the account password. | 288 | form.append("metadata", JSON.stringify({ ...metadata, ...observabilityMetadata(this.tracesDestination) })); |
| 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. | 289 | form.append("index.js", new File([APP_WORKER], "index.js", { type: "application/javascript+module" })); |
| 290 | await this.call("PUT", this.scriptPath(script), form); | |
| 291 | } | |
| 292 | ||
| Merge branch 'worktree-agent-a4d0c5ee9c2f877aa' | 293 | /** |
| 294 | * The Durable Object namespace of a container app's Worker: what its | |
| 295 | * Container application is attached to. Read from the Worker's own | |
| 296 | * `APP` binding, which names it; else looked for among the account's | |
| 297 | * namespaces, by class and script (a namespace there is matched to this | |
| 298 | * dispatch namespace when Cloudflare says which it is in, which its | |
| 299 | * documented schema does not promise). | |
| 300 | */ | |
| 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. | 301 | async appNamespace(script: string): Promise<string> { |
| Merge branch 'worktree-agent-a4d0c5ee9c2f877aa' | 302 | const bindings = await this.call<{ type: string; name: string; namespace_id?: string }[]>("GET", `${this.scriptPath(script)}/bindings`).catch( |
| 303 | () => [], | |
| 304 | ); | |
| 305 | const bound = bindings.find((b) => b.type === "durable_object_namespace" && b.name === "APP" && b.namespace_id); | |
| 306 | if (bound?.namespace_id) return bound.namespace_id; | |
| 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. | 307 | for (let page = 1; page <= 20; page++) { |
| 308 | const namespaces = await this.call<{ id: string; class: string; script: string; dispatch_namespace?: string }[]>( | |
| 309 | "GET", | |
| 310 | `/accounts/${this.account}/workers/durable_objects/namespaces?per_page=1000&page=${page}`, | |
| 311 | ); | |
| Merge branch 'worktree-agent-a4d0c5ee9c2f877aa' | 312 | const found = namespaces.find( |
| 313 | (n) => n.class === APP_CLASS && n.script === script && (n.dispatch_namespace === undefined || n.dispatch_namespace === this.namespace), | |
| 314 | ); | |
| 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. | 315 | if (found) return found.id; |
| 316 | if (namespaces.length < 1000) break; | |
| 317 | } | |
| 318 | throw new Error(`The app's Durable Object namespace was not found for ${script}.`); | |
| 319 | } | |
| 320 | ||
| 321 | /** The Container application named `name`, if there is one. */ | |
| 322 | async containerApplication(name: string): Promise<{ id: string } | null> { | |
| 323 | const apps = await this.call<{ id: string; name: string }[]>("GET", `/accounts/${this.account}/containers/applications`); | |
| 324 | return apps.find((app) => app.name === name) ?? null; | |
| 325 | } | |
| 326 | ||
| 327 | /** | |
| 328 | * Runs `image` at `instanceType` for the app `script`: its Container | |
| 329 | * application is made the first time, and rolled out to after. | |
| 330 | */ | |
| 331 | async runContainer(script: string, image: string, instanceType: string, description: string): Promise<string> { | |
| 332 | const name = applicationName(script); | |
| 333 | const existing = await this.containerApplication(name); | |
| 334 | if (existing) { | |
| 335 | await this.call( | |
| 336 | "POST", | |
| 337 | `/accounts/${this.account}/containers/applications/${existing.id}/rollouts`, | |
| 338 | JSON.stringify(rolloutRequest(image, instanceType, description)), | |
| 339 | "application/json", | |
| 340 | ); | |
| 341 | return existing.id; | |
| 342 | } | |
| 343 | const namespaceId = await this.appNamespace(script); | |
| 344 | const created = await this.call<{ id: string }>( | |
| 345 | "POST", | |
| 346 | `/accounts/${this.account}/containers/applications`, | |
| 347 | JSON.stringify(applicationRequest(name, image, instanceType, namespaceId)), | |
| 348 | "application/json", | |
| 349 | ); | |
| 350 | return created.id; | |
| 351 | } | |
| 352 | ||
| 353 | /** Deletes the app's Container application, and its instances with it. Already gone is fine. */ | |
| 354 | async deleteContainer(script: string, id?: string | null): Promise<void> { | |
| 355 | const found = id ? { id } : await this.containerApplication(applicationName(script)).catch(() => null); | |
| 356 | if (!found) return; | |
| 357 | try { | |
| 358 | await this.call("DELETE", `/accounts/${this.account}/containers/applications/${found.id}`); | |
| 359 | } catch (error) { | |
| 360 | if (!/404|not found/i.test(String(error))) throw error; | |
| 361 | } | |
| 362 | } | |
| 363 | ||
| Billing: AI Gateway's analytics are read with the token that can see them, and a gateway that priced nothing is said | 364 | /** Every app in the namespace, with when it was last changed. */ |
| 365 | async listScripts(): Promise<{ id: string; modified_on: string }[]> { | |
| 366 | return this.call<{ id: string; modified_on: string }[]>( | |
| 367 | "GET", | |
| 368 | `/accounts/${this.account}/workers/dispatch/namespaces/${this.namespace}/scripts`, | |
| 369 | ); | |
| 370 | } | |
| 371 | ||
| 372 | /** | |
| 373 | * Takes an app down. Already gone is fine. | |
| 374 | * | |
| 375 | * A deleted app can keep answering for a while where Cloudflare still has | |
| 376 | * it warm. So it is first replaced by a notice that it is down, which | |
| 377 | * reaches the edge as fast as any deploy, and deleted after. | |
| 378 | */ | |
| 379 | async deleteScript(script: string): Promise<void> { | |
| 380 | await this.placeholder(script, TAKEN_DOWN, "taken-down").catch(() => undefined); | |
| 381 | try { | |
| 382 | await this.call("DELETE", `${this.scriptPath(script)}?force=true`); | |
| 383 | } catch (error) { | |
| 384 | if (!/404|not found|10007/i.test(String(error))) throw error; | |
| 385 | } | |
| 386 | } | |
| 387 | ||
| 388 | /** | |
| 389 | * Replaces an app with a notice that it is paused: its workspace reached | |
| 390 | * its limit. The notice costs next to nothing to answer with, and the app | |
| 391 | * comes back by being deployed again. | |
| 392 | */ | |
| 393 | async pauseScript(script: string): Promise<void> { | |
| 394 | await this.placeholder(script, PAUSED, "paused"); | |
| 395 | } | |
| 396 | ||
| 397 | /** | |
| 398 | * Replaces an app with a redirect to `targetHost`, path and query kept: | |
| 399 | * what an app's old address answers after its workspace is renamed. | |
| 400 | */ | |
| 401 | async redirectScript(script: string, targetHost: string): Promise<void> { | |
| 402 | await this.placeholder(script, redirectScript(targetHost), "redirect"); | |
| 403 | } | |
| 404 | ||
| 405 | private async placeholder(script: string, code: string, tag: string): Promise<void> { | |
| 406 | const form = new FormData(); | |
| 407 | form.append( | |
| 408 | "metadata", | |
| 409 | JSON.stringify({ main_module: "index.js", compatibility_date: "2026-09-26", bindings: [], tags: [tag] }), | |
| 410 | ); | |
| 411 | form.append("index.js", new File([code], "index.js", { type: "application/javascript+module" })); | |
| 412 | await this.call("PUT", this.scriptPath(script), form); | |
| 413 | } | |
| 414 | ||
| 415 | /** | |
| 416 | * Requests and CPU time per app over a period, from Workers analytics. | |
| 417 | * Apps with no traffic are absent. | |
| 418 | */ | |
| 419 | async usage( | |
| 420 | scripts: string[], | |
| 421 | since: string, | |
| 422 | until: string, | |
| 423 | ): Promise<Map<string, { requests: number; cpuMs: number }>> { | |
| 424 | const totals = new Map<string, { requests: number; cpuMs: number }>(); | |
| 425 | if (scripts.length === 0) return totals; | |
| 426 | const query = (withCpu: boolean) => `query ($account: string!, $since: Time!, $until: Time!, $scripts: [string!]) { | |
| 427 | viewer { accounts(filter: { accountTag: $account }) { | |
| 428 | workersInvocationsAdaptive(limit: 10000, filter: { datetime_geq: $since, datetime_lt: $until, scriptName_in: $scripts }) { | |
| 429 | sum { requests${withCpu ? " cpuTimeUs" : ""} } | |
| 430 | dimensions { scriptName } | |
| 431 | } | |
| 432 | } } | |
| 433 | }`; | |
| 434 | type Row = { sum: { requests: number; cpuTimeUs?: number }; dimensions: { scriptName: string } }; | |
| 435 | const ask = async (withCpu: boolean) => { | |
| 436 | const response = await fetch(`${API}/graphql`, { | |
| 437 | method: "POST", | |
| 438 | headers: { authorization: `Bearer ${this.token}`, "content-type": "application/json" }, | |
| 439 | body: JSON.stringify({ | |
| 440 | query: query(withCpu), | |
| 441 | variables: { account: this.account, since, until, scripts }, | |
| 442 | }), | |
| 443 | }); | |
| 444 | return (await response.json()) as { | |
| 445 | data?: { viewer: { accounts: { workersInvocationsAdaptive: Row[] }[] } }; | |
| 446 | errors?: { message: string }[] | null; | |
| 447 | }; | |
| 448 | }; | |
| 449 | let answer = await ask(true); | |
| 450 | // CPU time is counted where analytics offers it; requests always. | |
| 451 | if (answer.errors?.length) answer = await ask(false); | |
| 452 | if (answer.errors?.length || !answer.data) { | |
| 453 | throw new Error(`Workers analytics refused: ${answer.errors?.map((e) => e.message).join("; ")}`); | |
| 454 | } | |
| 455 | for (const row of answer.data.viewer.accounts[0]?.workersInvocationsAdaptive ?? []) { | |
| 456 | const seen = totals.get(row.dimensions.scriptName) ?? { requests: 0, cpuMs: 0 }; | |
| 457 | seen.requests += row.sum.requests; | |
| 458 | seen.cpuMs += Math.ceil((row.sum.cpuTimeUs ?? 0) / 1000); | |
| 459 | totals.set(row.dimensions.scriptName, seen); | |
| 460 | } | |
| 461 | return totals; | |
| 462 | } | |
| 463 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.