@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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +87 -0
  3. package/package.json +48 -0
  4. package/src/ai/aiManager.ts +227 -0
  5. package/src/ai/vectorizeManager.ts +161 -0
  6. package/src/ai/vectorizeProvisioner.ts +266 -0
  7. package/src/client/accounts.ts +80 -0
  8. package/src/client/clients.ts +244 -0
  9. package/src/client/errors.ts +143 -0
  10. package/src/client/manager.ts +85 -0
  11. package/src/d1/d1Manager.ts +171 -0
  12. package/src/d1/d1PreparedStatement.ts +114 -0
  13. package/src/d1/d1Provisioner.ts +75 -0
  14. package/src/email/emailRoutingManager.ts +143 -0
  15. package/src/email/emailSendManager.ts +81 -0
  16. package/src/env/devVars.ts +90 -0
  17. package/src/hostnames/customHostnamesManager.ts +134 -0
  18. package/src/kv/kvManager.ts +202 -0
  19. package/src/kv/kvProvisioner.ts +80 -0
  20. package/src/media/assetSeeder.ts +87 -0
  21. package/src/media/imageManager.ts +125 -0
  22. package/src/media/ownership.ts +59 -0
  23. package/src/media/streamManager.ts +198 -0
  24. package/src/queue/queueManager.ts +185 -0
  25. package/src/r2/r2Credentials.ts +17 -0
  26. package/src/r2/r2Manager.ts +548 -0
  27. package/src/r2/r2Provisioner.ts +99 -0
  28. package/src/secrets/secretsStoreManager.ts +177 -0
  29. package/src/secrets/secretsStores.ts +75 -0
  30. package/src/test-utils/emailRoutingRules.ts +122 -0
  31. package/src/test-utils/fixtureReportSetup.ts +31 -0
  32. package/src/test-utils/fixtures.ts +372 -0
  33. package/src/test-utils/harness.ts +413 -0
  34. package/src/test-utils/inboundRecorder.ts +189 -0
  35. package/src/test-utils/integrationSetup.ts +46 -0
  36. package/src/test-utils/reap.ts +297 -0
  37. package/src/tokens/accountTokensManager.ts +334 -0
  38. package/src/tokens/permissions.ts +67 -0
  39. package/src/tokens/profiles.ts +238 -0
  40. package/src/turnstile/turnstileManager.ts +177 -0
  41. package/src/user/userManager.ts +73 -0
  42. package/src/workers/buildsManager.ts +348 -0
  43. package/src/workers/buildsTypes.ts +122 -0
  44. package/src/workers/workersBuildEvent.ts +48 -0
  45. package/src/workers/workersManager.ts +423 -0
  46. package/src/workers/workersProvisioner.ts +167 -0
  47. package/src/workflows/stepFailure.ts +280 -0
  48. package/src/workflows/workflowsClient.ts +213 -0
  49. 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
+ }