@pithy-sh/cloudflare 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/package.json +48 -0
- package/src/ai/aiManager.ts +227 -0
- package/src/ai/vectorizeManager.ts +161 -0
- package/src/ai/vectorizeProvisioner.ts +266 -0
- package/src/client/accounts.ts +80 -0
- package/src/client/clients.ts +244 -0
- package/src/client/errors.ts +143 -0
- package/src/client/manager.ts +85 -0
- package/src/d1/d1Manager.ts +171 -0
- package/src/d1/d1PreparedStatement.ts +114 -0
- package/src/d1/d1Provisioner.ts +75 -0
- package/src/email/emailRoutingManager.ts +143 -0
- package/src/email/emailSendManager.ts +81 -0
- package/src/env/devVars.ts +90 -0
- package/src/hostnames/customHostnamesManager.ts +134 -0
- package/src/kv/kvManager.ts +202 -0
- package/src/kv/kvProvisioner.ts +80 -0
- package/src/media/assetSeeder.ts +87 -0
- package/src/media/imageManager.ts +125 -0
- package/src/media/ownership.ts +59 -0
- package/src/media/streamManager.ts +198 -0
- package/src/queue/queueManager.ts +185 -0
- package/src/r2/r2Credentials.ts +17 -0
- package/src/r2/r2Manager.ts +548 -0
- package/src/r2/r2Provisioner.ts +99 -0
- package/src/secrets/secretsStoreManager.ts +177 -0
- package/src/secrets/secretsStores.ts +75 -0
- package/src/test-utils/emailRoutingRules.ts +122 -0
- package/src/test-utils/fixtureReportSetup.ts +31 -0
- package/src/test-utils/fixtures.ts +372 -0
- package/src/test-utils/harness.ts +413 -0
- package/src/test-utils/inboundRecorder.ts +189 -0
- package/src/test-utils/integrationSetup.ts +46 -0
- package/src/test-utils/reap.ts +297 -0
- package/src/tokens/accountTokensManager.ts +334 -0
- package/src/tokens/permissions.ts +67 -0
- package/src/tokens/profiles.ts +238 -0
- package/src/turnstile/turnstileManager.ts +177 -0
- package/src/user/userManager.ts +73 -0
- package/src/workers/buildsManager.ts +348 -0
- package/src/workers/buildsTypes.ts +122 -0
- package/src/workers/workersBuildEvent.ts +48 -0
- package/src/workers/workersManager.ts +423 -0
- package/src/workers/workersProvisioner.ts +167 -0
- package/src/workflows/stepFailure.ts +280 -0
- package/src/workflows/workflowsClient.ts +213 -0
- package/src/zones/zonesManager.ts +92 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { NotFoundError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
5
|
+
import type { Cloudflare } from "cloudflare";
|
|
6
|
+
import type { RouteCreateResponse, RouteListResponse } from "cloudflare/resources/workers/routes";
|
|
7
|
+
import type { Deployment } from "cloudflare/resources/workers/scripts/deployments";
|
|
8
|
+
import type { Script } from "cloudflare/resources/workers/scripts/scripts";
|
|
9
|
+
import type { SecretListResponse } from "cloudflare/resources/workers/scripts/secrets";
|
|
10
|
+
import type { SettingEditParams } from "cloudflare/resources/workers/scripts/settings";
|
|
11
|
+
import type { VersionGetResponse, VersionListResponse } from "cloudflare/resources/workers/scripts/versions";
|
|
12
|
+
import { CloudflareInvalidResponseError, cloudflareRequest, messageOf } from "../client/errors";
|
|
13
|
+
import { CloudflareManager } from "../client/manager";
|
|
14
|
+
|
|
15
|
+
/** Per-call SDK timeout + retry budget for Worker management operations. */
|
|
16
|
+
const requestOptions: Cloudflare.RequestOptions = { timeout: 10000, maxRetries: 3 };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The placeholder module uploaded when a Worker script is first created. A Worker upload requires
|
|
20
|
+
* at least one module file plus `main_module` pointing at it; the real build output replaces this
|
|
21
|
+
* via a later version upload. This stub returns 503 while the worker is being provisioned.
|
|
22
|
+
*/
|
|
23
|
+
const PLACEHOLDER_MODULE = "index.js";
|
|
24
|
+
const PLACEHOLDER_BODY =
|
|
25
|
+
"export default { async fetch() { return new Response('Provisioning...', { status: 503 }); } };";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* An ISO date, the only spelling a compatibility date has. Refused here so a typo is a `ValidationError`
|
|
29
|
+
* naming the argument rather than a 400 from Cloudflare naming the request.
|
|
30
|
+
*/
|
|
31
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
32
|
+
|
|
33
|
+
/** The content type every module part carries. ES modules only — see {@link WorkerModule}. */
|
|
34
|
+
const MODULE_CONTENT_TYPE = "application/javascript+module";
|
|
35
|
+
|
|
36
|
+
/** Worker settings a caller may edit (observability, logpush, tags, …); the account id is supplied. */
|
|
37
|
+
export type WorkerSettings = Omit<SettingEditParams, "account_id">;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One ES-module file of a Worker upload.
|
|
41
|
+
*
|
|
42
|
+
* **ES modules only.** `name` becomes both the multipart part name and the upload's `main_module`,
|
|
43
|
+
* and the part is sent as `application/javascript+module`. Classic service-worker scripts — the
|
|
44
|
+
* `body_part` shape, with a global `addEventListener("fetch", …)` — are not supported by this
|
|
45
|
+
* manager and never were: every upload it has ever sent set `main_module`. {@link
|
|
46
|
+
* CloudflareWorkersManager.createWorker} refuses a `body_part` in metadata rather than sending a
|
|
47
|
+
* request with both shapes half-declared.
|
|
48
|
+
*/
|
|
49
|
+
export interface WorkerModule {
|
|
50
|
+
/** The module's filename, e.g. `index.js`. Becomes the part name and the upload's `main_module`. */
|
|
51
|
+
name: string;
|
|
52
|
+
/** The module's ES-module source, uploaded verbatim as `application/javascript+module`. */
|
|
53
|
+
body: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The placeholder module `createWorker` uploads when the caller supplies none. */
|
|
57
|
+
const PLACEHOLDER: WorkerModule = { name: PLACEHOLDER_MODULE, body: PLACEHOLDER_BODY };
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Out-of-Worker Workers access over the REST API: script create/list/delete, subdomain + settings,
|
|
61
|
+
* versions, deployments, secrets, and routes from a CLI/CI/provisioning context. Inside a Worker you
|
|
62
|
+
* manage scripts through wrangler/bindings; this manager is the REST counterpart, addressed by
|
|
63
|
+
* account (and zone, for routes).
|
|
64
|
+
*/
|
|
65
|
+
export class CloudflareWorkersManager extends CloudflareManager {
|
|
66
|
+
/** List every Worker script on the account. */
|
|
67
|
+
async listWorkers(): Promise<Script[]> {
|
|
68
|
+
return cloudflareRequest("list workers", async () => {
|
|
69
|
+
const scripts: Script[] = [];
|
|
70
|
+
for await (const script of this.getClient().workers.scripts.list({ account_id: this.accountId })) {
|
|
71
|
+
scripts.push(script);
|
|
72
|
+
}
|
|
73
|
+
return scripts;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Find a Worker script by name. Returns null when none matches. */
|
|
78
|
+
async getWorker(scriptName: string): Promise<Script | null> {
|
|
79
|
+
const scripts = await this.listWorkers();
|
|
80
|
+
return scripts.find((script) => script.id === scriptName) ?? null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve a Worker's immutable `id` (the hex UUID) from the beta `/workers/workers` endpoint. CF
|
|
85
|
+
* Builds' `external_script_id` uses this immutable id, not the worker name. This is an explicit
|
|
86
|
+
* get: it throws `core/not_found` when no worker on the account carries the given name.
|
|
87
|
+
*/
|
|
88
|
+
async getWorkerInternalId(workerName: string): Promise<string> {
|
|
89
|
+
return cloudflareRequest(`get worker internal id for '${workerName}'`, async () => {
|
|
90
|
+
for await (const worker of this.getClient().workers.beta.workers.list({ account_id: this.accountId })) {
|
|
91
|
+
if (worker.name === workerName) return worker.id;
|
|
92
|
+
}
|
|
93
|
+
throw new NotFoundError({
|
|
94
|
+
message: `No Worker named '${workerName}' exists on the account.`,
|
|
95
|
+
detail: `Beta workers list returned no entry with name '${workerName}'.`,
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Create (upload) a Worker script. With no `module` the placeholder above is uploaded and the real
|
|
102
|
+
* build output replaces it via a later version upload; pass one to upload real source. `metadata`
|
|
103
|
+
* is merged into the upload's metadata — bindings, compatibility flags, tags — and this method
|
|
104
|
+
* fixes `main_module`.
|
|
105
|
+
*
|
|
106
|
+
* **`compatibilityDate` is required, and there is no default (#396).** This method used to supply
|
|
107
|
+
* `2026-04-07` when the caller named none, which is one date below the floor `compatibility.ts`
|
|
108
|
+
* holds every other Worker in this repository to, and the one date #388's gate could not reach —
|
|
109
|
+
* because it is TypeScript rather than a `wrangler.jsonc`, and the gate reads manifests.
|
|
110
|
+
*
|
|
111
|
+
* Moving it to the floor was the obvious answer and it is the wrong one. **A compatibility date is a
|
|
112
|
+
* behavior contract, not a version number** — it is the date workerd pretends it is — and this one
|
|
113
|
+
* lands on Workers in accounts that are not ours. Re-picking the number changes what an existing
|
|
114
|
+
* caller's Workers run, silently, for somebody who never asked; and the new number is stale on
|
|
115
|
+
* exactly the schedule the old one was, with the same gate unable to see it. `compatibility.ts` makes
|
|
116
|
+
* that argument about `2026-03-03` in as many words: *the minimum that fixes the last bug is exactly
|
|
117
|
+
* the number `2025-01-01` once was.*
|
|
118
|
+
*
|
|
119
|
+
* Requiring the date removes the class instead of re-picking the number, which is the move #377, #366
|
|
120
|
+
* and #394 each took. It is also the cheaper break: a caller who wanted `2026-04-07` writes
|
|
121
|
+
* `2026-04-07` and gets precisely what they had, and everyone else finds out at compile time rather
|
|
122
|
+
* than from a behavior change in production. `WorkersProvisioner` already promised this — *"it
|
|
123
|
+
* carries no environment- or product-specific defaults — every name, command, and env var is supplied
|
|
124
|
+
* by the caller"* — and the manager under it was the one place that was untrue.
|
|
125
|
+
*
|
|
126
|
+
* `metadata` may **not** also carry `compatibility_date`. Two ways to state one contract is a
|
|
127
|
+
* precedence rule to remember, and this method exists to have one statement rather than two.
|
|
128
|
+
*
|
|
129
|
+
* **The multipart request is built here rather than through `workers.scripts.update`, and that is
|
|
130
|
+
* a fix rather than a preference (#373).** The typed SDK's `update` pins
|
|
131
|
+
* `Content-Type: application/javascript` on the request and *then* lets its uploader turn the body
|
|
132
|
+
* into `FormData`. Cloudflare believes the header, parses the multipart envelope as a classic
|
|
133
|
+
* service-worker script, and rejects every upload with `10021 Uncaught SyntaxError: Invalid
|
|
134
|
+
* left-hand side expression in prefix operation at worker.js:1:4` — the leading `------WebKit…`
|
|
135
|
+
* boundary read as prefix `--` operators. Its form is wrong twice over besides: metadata is
|
|
136
|
+
* flattened to `metadata[main_module]` fields instead of one JSON part, and the module is appended
|
|
137
|
+
* as `files[]` rather than under the filename `main_module` names. So the form is assembled here
|
|
138
|
+
* and handed to the SDK's own `put`, which keeps auth, retries, timeout and error mapping intact.
|
|
139
|
+
*/
|
|
140
|
+
async createWorker(
|
|
141
|
+
scriptName: string,
|
|
142
|
+
compatibilityDate: string,
|
|
143
|
+
metadata: Record<string, unknown> = {},
|
|
144
|
+
module: WorkerModule = PLACEHOLDER,
|
|
145
|
+
): Promise<Script> {
|
|
146
|
+
if ("body_part" in metadata) {
|
|
147
|
+
throw new ValidationError({
|
|
148
|
+
message: "This client uploads ES-module Workers only.",
|
|
149
|
+
action: "Remove `body_part` from the metadata and pass the script as a module.",
|
|
150
|
+
detail: `createWorker('${scriptName}') was given a 'body_part', the classic service-worker shape. Every upload sets 'main_module'.`,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if ("compatibility_date" in metadata) {
|
|
155
|
+
throw new ValidationError({
|
|
156
|
+
message: "A Worker's compatibility date is named once, as an argument.",
|
|
157
|
+
action: "Remove `compatibility_date` from the metadata and pass it as the second argument.",
|
|
158
|
+
detail: `createWorker('${scriptName}') was given a 'compatibility_date' in metadata as well as an argument. Two statements of one behavior contract is a precedence rule nobody should have to know.`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!ISO_DATE.test(compatibilityDate)) {
|
|
163
|
+
throw new ValidationError({
|
|
164
|
+
message: "A compatibility date is an ISO date, like 2026-06-01.",
|
|
165
|
+
action: "Pass the date as YYYY-MM-DD.",
|
|
166
|
+
detail: `createWorker('${scriptName}') was given the compatibility date '${compatibilityDate}'.`,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const form = new FormData();
|
|
171
|
+
form.append(
|
|
172
|
+
"metadata",
|
|
173
|
+
new Blob(
|
|
174
|
+
[
|
|
175
|
+
JSON.stringify({
|
|
176
|
+
...metadata,
|
|
177
|
+
compatibility_date: compatibilityDate,
|
|
178
|
+
main_module: module.name,
|
|
179
|
+
}),
|
|
180
|
+
],
|
|
181
|
+
{ type: "application/json" },
|
|
182
|
+
),
|
|
183
|
+
);
|
|
184
|
+
form.append(module.name, new Blob([module.body], { type: MODULE_CONTENT_TYPE }), module.name);
|
|
185
|
+
|
|
186
|
+
return cloudflareRequest(`create worker '${scriptName}'`, async () => {
|
|
187
|
+
const envelope = await this.getClient().put<{ result: Script | null }>(
|
|
188
|
+
`/accounts/${this.accountId}/workers/scripts/${scriptName}`,
|
|
189
|
+
{ body: form, ...requestOptions },
|
|
190
|
+
);
|
|
191
|
+
if (!envelope.result) {
|
|
192
|
+
throw new CloudflareInvalidResponseError({
|
|
193
|
+
message: "Cloudflare accepted the Worker upload but returned no script.",
|
|
194
|
+
detail: `Upload of '${scriptName}' returned a success envelope with a null result.`,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return envelope.result;
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Configure the workers.dev subdomain and preview URLs for a script. Both are controlled by the
|
|
203
|
+
* same endpoint — `previews_enabled` is always sent explicitly so CF does not default it to true.
|
|
204
|
+
*/
|
|
205
|
+
async setSubdomainSettings(scriptName: string, enabled: boolean, previewsEnabled = false): Promise<void> {
|
|
206
|
+
await cloudflareRequest(`set subdomain settings for '${scriptName}'`, () =>
|
|
207
|
+
this.getClient().workers.scripts.subdomain.create(scriptName, {
|
|
208
|
+
account_id: this.accountId,
|
|
209
|
+
enabled,
|
|
210
|
+
previews_enabled: previewsEnabled,
|
|
211
|
+
}),
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Edit a Worker's settings (observability, logpush, tags, …). */
|
|
216
|
+
async updateSettings(scriptName: string, settings: WorkerSettings): Promise<void> {
|
|
217
|
+
await cloudflareRequest(`update settings for '${scriptName}'`, () =>
|
|
218
|
+
this.getClient().workers.scripts.settings.edit(scriptName, { account_id: this.accountId, ...settings }),
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Delete a Worker script. */
|
|
223
|
+
async deleteWorker(scriptName: string): Promise<void> {
|
|
224
|
+
await cloudflareRequest(`delete worker '${scriptName}'`, () =>
|
|
225
|
+
this.getClient().workers.scripts.delete(scriptName, { account_id: this.accountId }, requestOptions),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** List every version of a Worker script. */
|
|
230
|
+
async listVersions(scriptName: string): Promise<VersionListResponse[]> {
|
|
231
|
+
return cloudflareRequest(`list versions for '${scriptName}'`, async () => {
|
|
232
|
+
const versions: VersionListResponse[] = [];
|
|
233
|
+
for await (const version of this.getClient().workers.scripts.versions.list(scriptName, {
|
|
234
|
+
account_id: this.accountId,
|
|
235
|
+
})) {
|
|
236
|
+
versions.push(version);
|
|
237
|
+
}
|
|
238
|
+
return versions;
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Get one version of a Worker script. */
|
|
243
|
+
async getVersion(scriptName: string, versionId: string): Promise<VersionGetResponse> {
|
|
244
|
+
return cloudflareRequest(`get version '${versionId}' for '${scriptName}'`, () =>
|
|
245
|
+
this.getClient().workers.scripts.versions.get(
|
|
246
|
+
versionId,
|
|
247
|
+
{ account_id: this.accountId, script_name: scriptName },
|
|
248
|
+
requestOptions,
|
|
249
|
+
),
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** List a Worker script's deployments. Empty when none exist. */
|
|
254
|
+
async listDeployments(scriptName: string): Promise<Deployment[]> {
|
|
255
|
+
return cloudflareRequest(`list deployments for '${scriptName}'`, async () => {
|
|
256
|
+
const result = await this.getClient().workers.scripts.deployments.list(
|
|
257
|
+
scriptName,
|
|
258
|
+
{ account_id: this.accountId },
|
|
259
|
+
requestOptions,
|
|
260
|
+
);
|
|
261
|
+
return result.deployments ?? [];
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Get one deployment of a Worker script. */
|
|
266
|
+
async getDeployment(scriptName: string, deploymentId: string): Promise<Deployment> {
|
|
267
|
+
return cloudflareRequest(`get deployment '${deploymentId}' for '${scriptName}'`, () =>
|
|
268
|
+
this.getClient().workers.scripts.deployments.get(
|
|
269
|
+
deploymentId,
|
|
270
|
+
{ account_id: this.accountId, script_name: scriptName },
|
|
271
|
+
requestOptions,
|
|
272
|
+
),
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Deploy a version to 100% of traffic for a Worker script. */
|
|
277
|
+
async createDeployment(scriptName: string, versionId: string): Promise<Deployment> {
|
|
278
|
+
return cloudflareRequest(`create deployment for '${scriptName}'`, () =>
|
|
279
|
+
this.getClient().workers.scripts.deployments.create(
|
|
280
|
+
scriptName,
|
|
281
|
+
{
|
|
282
|
+
account_id: this.accountId,
|
|
283
|
+
strategy: "percentage",
|
|
284
|
+
versions: [{ percentage: 100, version_id: versionId }],
|
|
285
|
+
},
|
|
286
|
+
requestOptions,
|
|
287
|
+
),
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Set a secret on a Worker script. */
|
|
292
|
+
async addSecret(scriptName: string, name: string, value: string): Promise<void> {
|
|
293
|
+
await cloudflareRequest(`add secret '${name}' to '${scriptName}'`, () =>
|
|
294
|
+
this.getClient().workers.scripts.secrets.update(
|
|
295
|
+
scriptName,
|
|
296
|
+
{ account_id: this.accountId, name, text: value, type: "secret_text" },
|
|
297
|
+
requestOptions,
|
|
298
|
+
),
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Delete a secret from a Worker script. */
|
|
303
|
+
async deleteSecret(scriptName: string, secretName: string): Promise<void> {
|
|
304
|
+
await cloudflareRequest(`delete secret '${secretName}' from '${scriptName}'`, () =>
|
|
305
|
+
this.getClient().workers.scripts.secrets.delete(
|
|
306
|
+
secretName,
|
|
307
|
+
{ account_id: this.accountId, script_name: scriptName },
|
|
308
|
+
requestOptions,
|
|
309
|
+
),
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** List a Worker script's secrets. */
|
|
314
|
+
async listSecrets(scriptName: string): Promise<SecretListResponse[]> {
|
|
315
|
+
return cloudflareRequest(`list secrets for '${scriptName}'`, async () => {
|
|
316
|
+
const secrets: SecretListResponse[] = [];
|
|
317
|
+
for await (const secret of this.getClient().workers.scripts.secrets.list(scriptName, {
|
|
318
|
+
account_id: this.accountId,
|
|
319
|
+
})) {
|
|
320
|
+
secrets.push(secret);
|
|
321
|
+
}
|
|
322
|
+
return secrets;
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Create a Workers Route on a zone, mapping a hostname pattern to a script. Idempotent: returns the
|
|
328
|
+
* existing route when one with the same pattern is already present on the zone.
|
|
329
|
+
*/
|
|
330
|
+
async addRoute(
|
|
331
|
+
zoneId: string,
|
|
332
|
+
pattern: string,
|
|
333
|
+
scriptName: string,
|
|
334
|
+
): Promise<RouteCreateResponse | RouteListResponse> {
|
|
335
|
+
const existing = await this.getRoute(zoneId, pattern);
|
|
336
|
+
if (existing) return existing;
|
|
337
|
+
return cloudflareRequest(`add worker route '${pattern}'`, () =>
|
|
338
|
+
this.getClient().workers.routes.create({ zone_id: zoneId, pattern, script: scriptName }, requestOptions),
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Find a Workers Route on a zone by its pattern. Returns null when none matches. */
|
|
343
|
+
async getRoute(zoneId: string, pattern: string): Promise<RouteListResponse | null> {
|
|
344
|
+
return cloudflareRequest(`get worker route '${pattern}'`, async () => {
|
|
345
|
+
for await (const route of this.getClient().workers.routes.list({ zone_id: zoneId })) {
|
|
346
|
+
if (route.pattern === pattern) return route;
|
|
347
|
+
}
|
|
348
|
+
return null;
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Delete a Workers Route from a zone. */
|
|
353
|
+
async removeRoute(zoneId: string, routeId: string): Promise<void> {
|
|
354
|
+
await cloudflareRequest(`remove worker route '${routeId}'`, () =>
|
|
355
|
+
this.getClient().workers.routes.delete(routeId, { zone_id: zoneId }, requestOptions),
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Resolve a queue's id (UUID) from its name. Returns null when no queue on the account matches. */
|
|
360
|
+
async findQueueIdByName(queueName: string): Promise<string | null> {
|
|
361
|
+
return cloudflareRequest(`find queue '${queueName}'`, async () => {
|
|
362
|
+
for await (const queue of this.getClient().queues.list({ account_id: this.accountId })) {
|
|
363
|
+
if (queue.queue_name === queueName) return queue.queue_id ?? null;
|
|
364
|
+
}
|
|
365
|
+
return null;
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Subscribe a queue to a Worker's Builds lifecycle events (started/succeeded/failed/canceled).
|
|
371
|
+
* Idempotent: a CF "already exists" conflict (405 "multiple subscriptions", or 409 "already
|
|
372
|
+
* exists") is treated as success rather than re-thrown.
|
|
373
|
+
*/
|
|
374
|
+
async subscribeBuildEvents(subscriptionName: string, queueId: string, workerName: string): Promise<void> {
|
|
375
|
+
await cloudflareRequest(`subscribe build events for '${workerName}'`, async () => {
|
|
376
|
+
try {
|
|
377
|
+
await this.getClient().queues.subscriptions.create({
|
|
378
|
+
account_id: this.accountId,
|
|
379
|
+
name: subscriptionName,
|
|
380
|
+
destination: { type: "queues.queue", queue_id: queueId },
|
|
381
|
+
source: { type: "workersBuilds.worker", worker_name: workerName },
|
|
382
|
+
events: ["build.started", "build.succeeded", "build.failed", "build.canceled"],
|
|
383
|
+
});
|
|
384
|
+
} catch (error) {
|
|
385
|
+
const message = messageOf(error);
|
|
386
|
+
// CF returns 405 "multiple subscriptions on the same resource" (or 409 "already exists" on
|
|
387
|
+
// older envs) when a subscription is already present. Treat that as success — idempotent.
|
|
388
|
+
if (/\b409\b/.test(message) || /already exists/i.test(message) || /multiple subscriptions/i.test(message)) {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
throw error;
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* The account's `workers.dev` subdomain, or `null` if none is registered. Deploying a Worker that
|
|
398
|
+
* hosts Workflows requires the account to have one (a one-time account bootstrap), so provisioning
|
|
399
|
+
* checks this up front. The CF API returns a 404-style error when absent; that maps to `null`.
|
|
400
|
+
*/
|
|
401
|
+
async accountSubdomain(): Promise<string | null> {
|
|
402
|
+
try {
|
|
403
|
+
const result = await this.getClient().workers.subdomains.get({ account_id: this.accountId });
|
|
404
|
+
return result.subdomain ?? null;
|
|
405
|
+
} catch {
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
getServiceType(): string {
|
|
411
|
+
return "Cloudflare Workers";
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Prove access by listing the account's Workers. Never throws. */
|
|
415
|
+
async validateServiceAccess(): Promise<boolean> {
|
|
416
|
+
try {
|
|
417
|
+
await this.getClient().workers.scripts.list({ account_id: this.accountId });
|
|
418
|
+
return true;
|
|
419
|
+
} catch {
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { CloudflareNotConfiguredError } from "../client/errors";
|
|
5
|
+
import type { CloudflareManagerConfig } from "../client/manager";
|
|
6
|
+
import { CloudflareBuildsManager } from "./buildsManager";
|
|
7
|
+
import type { CfRepoConnection, CfTriggerEnvVar } from "./buildsTypes";
|
|
8
|
+
import { CloudflareWorkersManager, type WorkerSettings } from "./workersManager";
|
|
9
|
+
|
|
10
|
+
/** Describes the git repo a build trigger should clone, in provider-neutral terms. */
|
|
11
|
+
export interface BuildRepo {
|
|
12
|
+
/** The git provider hosting the repo. */
|
|
13
|
+
providerType: "gitlab" | "github";
|
|
14
|
+
/** The repository's id in the git provider. */
|
|
15
|
+
repoId: string;
|
|
16
|
+
/** The repo path relative to the provider account. */
|
|
17
|
+
repoName: string;
|
|
18
|
+
/** The provider account identifier (e.g. 'group:15366484'). */
|
|
19
|
+
providerAccountId: string;
|
|
20
|
+
/** The provider account display name. */
|
|
21
|
+
providerAccountName: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Everything `setupBuildTrigger` needs, all caller-supplied — no environment-specific defaults. */
|
|
25
|
+
export interface BuildTriggerSetup {
|
|
26
|
+
/** The Worker the trigger builds, by name (resolved to its immutable id internally). */
|
|
27
|
+
workerName: string;
|
|
28
|
+
/** The repo to clone. */
|
|
29
|
+
repo: BuildRepo;
|
|
30
|
+
/** The build token UUID the trigger authenticates with. */
|
|
31
|
+
buildTokenUuid: string;
|
|
32
|
+
/** The trigger's human-readable name. */
|
|
33
|
+
triggerName: string;
|
|
34
|
+
/** Branch patterns that auto-fire the trigger (must be non-empty). */
|
|
35
|
+
branchIncludes: string[];
|
|
36
|
+
/** The shell command that builds the Worker. */
|
|
37
|
+
buildCommand: string;
|
|
38
|
+
/** The shell command CF Builds runs to deploy after a build. */
|
|
39
|
+
deployCommand: string;
|
|
40
|
+
/** Environment variables to set on the trigger. */
|
|
41
|
+
envVars?: CfTriggerEnvVar[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Provisions Cloudflare Workers infrastructure over the REST API: create a Worker, set its secrets,
|
|
46
|
+
* wire a CF Builds trigger to a repo, register a Workers Route, and subscribe a queue to build
|
|
47
|
+
* events. A thin orchestrator over `CloudflareWorkersManager` + `CloudflareBuildsManager`.
|
|
48
|
+
*
|
|
49
|
+
* Each method is a discrete, idempotent step so a caller (e.g. a Workflow) can resume from the point
|
|
50
|
+
* of failure rather than from scratch. It carries no environment- or product-specific defaults —
|
|
51
|
+
* every name, command, and env var is supplied by the caller.
|
|
52
|
+
*/
|
|
53
|
+
export class WorkersProvisioner {
|
|
54
|
+
private readonly workers: CloudflareWorkersManager;
|
|
55
|
+
|
|
56
|
+
private readonly builds: CloudflareBuildsManager;
|
|
57
|
+
|
|
58
|
+
constructor(config: CloudflareManagerConfig) {
|
|
59
|
+
this.workers = new CloudflareWorkersManager(config);
|
|
60
|
+
this.builds = new CloudflareBuildsManager(config);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Create the Worker script, enable its workers.dev subdomain, and turn on observability. Returns
|
|
65
|
+
* the created script's id. workers.dev must be enabled via a separate call after script creation.
|
|
66
|
+
*
|
|
67
|
+
* `compatibilityDate` is passed through and not defaulted, which is this class's standing promise
|
|
68
|
+
* one level down: a compatibility date is a behavior contract in the caller's own account, and the
|
|
69
|
+
* manager stopped inventing one in #396.
|
|
70
|
+
*/
|
|
71
|
+
async createWorker(
|
|
72
|
+
workerName: string,
|
|
73
|
+
compatibilityDate: string,
|
|
74
|
+
metadata: Record<string, unknown> = {},
|
|
75
|
+
): Promise<string> {
|
|
76
|
+
const script = await this.workers.createWorker(workerName, compatibilityDate, metadata);
|
|
77
|
+
if (!script.id) {
|
|
78
|
+
throw new CloudflareNotConfiguredError({
|
|
79
|
+
message: `Worker '${workerName}' was created but returned no script id.`,
|
|
80
|
+
detail: "The Worker upload returned a script with no id.",
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
await this.workers.setSubdomainSettings(workerName, true, false);
|
|
84
|
+
await this.workers.updateSettings(workerName, { observability: { enabled: true } } as WorkerSettings);
|
|
85
|
+
return script.id;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Resolve the immutable hex id ("external_script_id") for a worker, stable across renames. */
|
|
89
|
+
async getWorkerInternalId(workerName: string): Promise<string> {
|
|
90
|
+
return this.workers.getWorkerInternalId(workerName);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Set a secret on a worker. */
|
|
94
|
+
async addWorkerSecret(workerName: string, key: string, value: string): Promise<void> {
|
|
95
|
+
await this.workers.addSecret(workerName, key, value);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Create the CF Builds repo connection + trigger and set its env vars, returning the trigger UUID.
|
|
100
|
+
* Idempotent — `createRepoConnection` is CF's upsert and `upsertTrigger` reconciles an existing
|
|
101
|
+
* trigger. The trigger is keyed on the worker's immutable internal id (resolved here), not its name.
|
|
102
|
+
*/
|
|
103
|
+
async setupBuildTrigger(setup: BuildTriggerSetup): Promise<string> {
|
|
104
|
+
const repoConnection = await this.builds.createRepoConnection({
|
|
105
|
+
providerType: setup.repo.providerType,
|
|
106
|
+
repoId: setup.repo.repoId,
|
|
107
|
+
repoName: setup.repo.repoName,
|
|
108
|
+
providerAccountId: setup.repo.providerAccountId,
|
|
109
|
+
providerAccountName: setup.repo.providerAccountName,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const externalScriptId = await this.workers.getWorkerInternalId(setup.workerName);
|
|
113
|
+
|
|
114
|
+
const trigger = await this.builds.upsertTrigger({
|
|
115
|
+
scriptName: externalScriptId,
|
|
116
|
+
repoConnectionId: repoConnection.repo_connection_uuid,
|
|
117
|
+
buildTokenUuid: setup.buildTokenUuid,
|
|
118
|
+
triggerName: setup.triggerName,
|
|
119
|
+
branchIncludes: setup.branchIncludes,
|
|
120
|
+
pathIncludes: ["*"],
|
|
121
|
+
buildCommand: setup.buildCommand,
|
|
122
|
+
deployCommand: setup.deployCommand,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if (setup.envVars && setup.envVars.length > 0) {
|
|
126
|
+
await this.builds.upsertTriggerEnvVars(trigger.trigger_uuid, setup.envVars);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return trigger.trigger_uuid;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Create (or upsert) only the repo connection. Useful when a caller manages the trigger itself. */
|
|
133
|
+
async setupRepoConnection(repo: BuildRepo): Promise<CfRepoConnection> {
|
|
134
|
+
return this.builds.createRepoConnection({
|
|
135
|
+
providerType: repo.providerType,
|
|
136
|
+
repoId: repo.repoId,
|
|
137
|
+
repoName: repo.repoName,
|
|
138
|
+
providerAccountId: repo.providerAccountId,
|
|
139
|
+
providerAccountName: repo.providerAccountName,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Register a Workers Route on a zone mapping `${hostname}/*` to the worker. Idempotent. Returns the
|
|
145
|
+
* route id (empty string when CF returns a route without one).
|
|
146
|
+
*/
|
|
147
|
+
async setupWorkerRoute(zoneId: string, workerName: string, hostname: string): Promise<string> {
|
|
148
|
+
const record = await this.workers.addRoute(zoneId, `${hostname}/*`, workerName);
|
|
149
|
+
return record.id ?? "";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Subscribe a named queue to the worker's Builds lifecycle events. Resolves the queue's id from its
|
|
154
|
+
* name first; throws `cloudflare/not_configured` when the queue does not exist. Idempotent.
|
|
155
|
+
*/
|
|
156
|
+
async setupBuildEventSubscription(workerName: string, queueName: string): Promise<void> {
|
|
157
|
+
const queueId = await this.workers.findQueueIdByName(queueName);
|
|
158
|
+
if (!queueId) {
|
|
159
|
+
throw new CloudflareNotConfiguredError({
|
|
160
|
+
message: `Queue '${queueName}' was not found on the account.`,
|
|
161
|
+
action: "Create the queue before provisioning build event subscriptions.",
|
|
162
|
+
detail: `No queue named '${queueName}' on the account.`,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
await this.workers.subscribeBuildEvents(`build-events-${workerName}`, queueId, workerName);
|
|
166
|
+
}
|
|
167
|
+
}
|