flagon-io/g1t

public

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/services/deployments/src/cloudflare.ts

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