Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
463 linesCodeBlameRaw

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 said1/**
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.12import { 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.13import { 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 said14import { redirectScript } from "./redirect";
15
16const API = "https://api.cloudflare.com/client/v4";
17
18export type Manifest = Record<string, { hash: string; size: number }>;
19
20/** A module of a Worker, as the sandbox sends it. */
21export type Module = { name: string; contentBase64: string; contentType: string };
22
23/** What the sandbox built: the Worker's code and settings. */
24export 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. */
38const 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. */
41const 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
47const 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. */
55const 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 */
64async 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 */
100async 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 said119const HTML_HANDLING =["auto-trailing-slash", "force-trailing-slash", "drop-trailing-slash", "none"];
120const NOT_FOUND_HANDLING = ["single-page-application", "404-page", "none"];
121
122export 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 said129 ) {}
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 said213 ...(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 said364 /** 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.