g1t/services/deployments/src/cloudflare.ts

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