@vibesdotdev/infra-cloudflare 0.0.1

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 (128) hide show
  1. package/README.md +107 -0
  2. package/SPEC.md +166 -0
  3. package/dist/cloudflare.plugin.d.ts +73 -0
  4. package/dist/cloudflare.plugin.d.ts.map +1 -0
  5. package/dist/cloudflare.plugin.js +334 -0
  6. package/dist/cloudflare.plugin.js.map +1 -0
  7. package/dist/implementations/alerts.descriptor.d.ts +13 -0
  8. package/dist/implementations/alerts.descriptor.d.ts.map +1 -0
  9. package/dist/implementations/alerts.descriptor.js +30 -0
  10. package/dist/implementations/alerts.descriptor.js.map +1 -0
  11. package/dist/implementations/alerts.impl.d.ts +35 -0
  12. package/dist/implementations/alerts.impl.d.ts.map +1 -0
  13. package/dist/implementations/alerts.impl.js +283 -0
  14. package/dist/implementations/alerts.impl.js.map +1 -0
  15. package/dist/implementations/kv.impl.d.ts +29 -0
  16. package/dist/implementations/kv.impl.d.ts.map +1 -0
  17. package/dist/implementations/kv.impl.js +36 -0
  18. package/dist/implementations/kv.impl.js.map +1 -0
  19. package/dist/implementations/logs.descriptor.d.ts +15 -0
  20. package/dist/implementations/logs.descriptor.d.ts.map +1 -0
  21. package/dist/implementations/logs.descriptor.js +26 -0
  22. package/dist/implementations/logs.descriptor.js.map +1 -0
  23. package/dist/implementations/logs.impl.d.ts +108 -0
  24. package/dist/implementations/logs.impl.d.ts.map +1 -0
  25. package/dist/implementations/logs.impl.js +154 -0
  26. package/dist/implementations/logs.impl.js.map +1 -0
  27. package/dist/implementations/observability.descriptor.d.ts +9 -0
  28. package/dist/implementations/observability.descriptor.d.ts.map +1 -0
  29. package/dist/implementations/observability.descriptor.js +22 -0
  30. package/dist/implementations/observability.descriptor.js.map +1 -0
  31. package/dist/implementations/observability.impl.d.ts +35 -0
  32. package/dist/implementations/observability.impl.d.ts.map +1 -0
  33. package/dist/implementations/observability.impl.js +229 -0
  34. package/dist/implementations/observability.impl.js.map +1 -0
  35. package/dist/implementations/pages.impl.d.ts +98 -0
  36. package/dist/implementations/pages.impl.d.ts.map +1 -0
  37. package/dist/implementations/pages.impl.js +132 -0
  38. package/dist/implementations/pages.impl.js.map +1 -0
  39. package/dist/implementations/queues.impl.d.ts +29 -0
  40. package/dist/implementations/queues.impl.d.ts.map +1 -0
  41. package/dist/implementations/queues.impl.js +34 -0
  42. package/dist/implementations/queues.impl.js.map +1 -0
  43. package/dist/implementations/r2.impl.d.ts +31 -0
  44. package/dist/implementations/r2.impl.d.ts.map +1 -0
  45. package/dist/implementations/r2.impl.js +41 -0
  46. package/dist/implementations/r2.impl.js.map +1 -0
  47. package/dist/implementations/rum.descriptor.d.ts +13 -0
  48. package/dist/implementations/rum.descriptor.d.ts.map +1 -0
  49. package/dist/implementations/rum.descriptor.js +32 -0
  50. package/dist/implementations/rum.descriptor.js.map +1 -0
  51. package/dist/implementations/rum.impl.d.ts +34 -0
  52. package/dist/implementations/rum.impl.d.ts.map +1 -0
  53. package/dist/implementations/rum.impl.js +153 -0
  54. package/dist/implementations/rum.impl.js.map +1 -0
  55. package/dist/implementations/web-app.impl.d.ts +294 -0
  56. package/dist/implementations/web-app.impl.d.ts.map +1 -0
  57. package/dist/implementations/web-app.impl.js +208 -0
  58. package/dist/implementations/web-app.impl.js.map +1 -0
  59. package/dist/implementations/workers.impl.d.ts +157 -0
  60. package/dist/implementations/workers.impl.d.ts.map +1 -0
  61. package/dist/implementations/workers.impl.js +247 -0
  62. package/dist/implementations/workers.impl.js.map +1 -0
  63. package/dist/index.d.ts +17 -0
  64. package/dist/index.d.ts.map +1 -0
  65. package/dist/index.js +12 -0
  66. package/dist/index.js.map +1 -0
  67. package/dist/pages.d.ts +9 -0
  68. package/dist/pages.d.ts.map +1 -0
  69. package/dist/pages.js +9 -0
  70. package/dist/pages.js.map +1 -0
  71. package/dist/regen.d.ts +58 -0
  72. package/dist/regen.d.ts.map +1 -0
  73. package/dist/regen.js +69 -0
  74. package/dist/regen.js.map +1 -0
  75. package/dist/secrets/cloudflare-api.descriptor.d.ts +18 -0
  76. package/dist/secrets/cloudflare-api.descriptor.d.ts.map +1 -0
  77. package/dist/secrets/cloudflare-api.descriptor.js +32 -0
  78. package/dist/secrets/cloudflare-api.descriptor.js.map +1 -0
  79. package/dist/secrets/cloudflare-api.impl.d.ts +30 -0
  80. package/dist/secrets/cloudflare-api.impl.d.ts.map +1 -0
  81. package/dist/secrets/cloudflare-api.impl.js +111 -0
  82. package/dist/secrets/cloudflare-api.impl.js.map +1 -0
  83. package/dist/secrets/cloudflare-secrets-store.descriptor.d.ts +10 -0
  84. package/dist/secrets/cloudflare-secrets-store.descriptor.d.ts.map +1 -0
  85. package/dist/secrets/cloudflare-secrets-store.descriptor.js +24 -0
  86. package/dist/secrets/cloudflare-secrets-store.descriptor.js.map +1 -0
  87. package/dist/secrets/cloudflare-secrets-store.impl.d.ts +27 -0
  88. package/dist/secrets/cloudflare-secrets-store.impl.d.ts.map +1 -0
  89. package/dist/secrets/cloudflare-secrets-store.impl.js +72 -0
  90. package/dist/secrets/cloudflare-secrets-store.impl.js.map +1 -0
  91. package/dist/secrets/index.d.ts +6 -0
  92. package/dist/secrets/index.d.ts.map +1 -0
  93. package/dist/secrets/index.js +6 -0
  94. package/dist/secrets/index.js.map +1 -0
  95. package/dist/secrets/resolve-cf-credentials.d.ts +18 -0
  96. package/dist/secrets/resolve-cf-credentials.d.ts.map +1 -0
  97. package/dist/secrets/resolve-cf-credentials.js +57 -0
  98. package/dist/secrets/resolve-cf-credentials.js.map +1 -0
  99. package/dist/web-app.d.ts +11 -0
  100. package/dist/web-app.d.ts.map +1 -0
  101. package/dist/web-app.js +11 -0
  102. package/dist/web-app.js.map +1 -0
  103. package/package.json +153 -0
  104. package/src/cloudflare.plugin.ts +477 -0
  105. package/src/implementations/alerts.descriptor.ts +33 -0
  106. package/src/implementations/alerts.impl.ts +332 -0
  107. package/src/implementations/kv.impl.ts +51 -0
  108. package/src/implementations/logs.descriptor.ts +29 -0
  109. package/src/implementations/logs.impl.ts +201 -0
  110. package/src/implementations/observability.descriptor.ts +25 -0
  111. package/src/implementations/observability.impl.ts +307 -0
  112. package/src/implementations/pages.impl.ts +189 -0
  113. package/src/implementations/queues.impl.ts +48 -0
  114. package/src/implementations/r2.impl.ts +58 -0
  115. package/src/implementations/rum.descriptor.ts +35 -0
  116. package/src/implementations/rum.impl.ts +192 -0
  117. package/src/implementations/web-app.impl.ts +494 -0
  118. package/src/implementations/workers.impl.ts +336 -0
  119. package/src/index.ts +60 -0
  120. package/src/pages.ts +18 -0
  121. package/src/regen.ts +87 -0
  122. package/src/secrets/cloudflare-api.descriptor.ts +35 -0
  123. package/src/secrets/cloudflare-api.impl.ts +131 -0
  124. package/src/secrets/cloudflare-secrets-store.descriptor.ts +27 -0
  125. package/src/secrets/cloudflare-secrets-store.impl.ts +87 -0
  126. package/src/secrets/index.ts +13 -0
  127. package/src/secrets/resolve-cf-credentials.ts +63 -0
  128. package/src/web-app.ts +32 -0
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Cloudflare Web Analytics RUM Implementation
3
+ *
4
+ * Maps the provider-agnostic `infra/rum` kind onto CF Web Analytics:
5
+ *
6
+ * GET /accounts/:account/rum/site_info/list list sites
7
+ * POST /accounts/:account/rum/site_info create site
8
+ * PATCH /accounts/:account/rum/site_info/:siteTag update site
9
+ * DELETE /accounts/:account/rum/site_info/:siteTag delete
10
+ *
11
+ * Credentials route through `resolveAdapterCredential` (vibes secrets
12
+ * → `secrets/store`); no CF-specific helpers. Token candidates default
13
+ * to CLOUDFLARE_API_TOKEN (Account → RUM: Edit scope required for write).
14
+ */
15
+
16
+ import * as z from 'zod/v4';
17
+ import {
18
+ resolveAdapterCredential,
19
+ type InfraRumDescriptor,
20
+ type RumImplementation,
21
+ type RumSiteSnapshot
22
+ } from '@vibesdotdev/infra-core';
23
+
24
+ export const CloudflareRumDescriptorSchema = z.object({
25
+ kind: z.literal('infra/rum'),
26
+ id: z.string().min(1),
27
+ name: z.string().min(1),
28
+ description: z.string().optional(),
29
+ adapter: z.literal('cloudflare-web-analytics'),
30
+ adapterConfig: z.record(z.string(), z.unknown()).optional(),
31
+ environment: z.string().default('local'),
32
+ siteToken: z.unknown().optional(),
33
+ hostnames: z.array(z.string()).default([]),
34
+ injection: z.string().default('sveltekit-hook'),
35
+ privacy: z.unknown().optional(),
36
+ enabled: z.boolean().default(true)
37
+ });
38
+
39
+ export type CloudflareRumDescriptorInput = z.input<typeof CloudflareRumDescriptorSchema>;
40
+ export type CloudflareRumDescriptor = z.infer<typeof CloudflareRumDescriptorSchema>;
41
+
42
+ interface CfApiBody<T> {
43
+ success: boolean;
44
+ result: T;
45
+ errors?: Array<{ code: number; message: string }>;
46
+ }
47
+
48
+ interface CfRumSite {
49
+ site_tag: string;
50
+ created: string;
51
+ auto_install?: boolean;
52
+ rules?: Array<{ host: string; paths?: string[]; is_paused?: boolean; inclusive?: boolean }>;
53
+ ruleset?: { id?: string; zone_tag?: string };
54
+ snippet?: string;
55
+ }
56
+
57
+ function fromCfSite(cf: CfRumSite): RumSiteSnapshot {
58
+ const hosts = (cf.rules ?? []).map((r) => r.host);
59
+ const enabled = !(cf.rules ?? []).every((r) => r.is_paused === true);
60
+ return {
61
+ siteTag: cf.site_tag,
62
+ hostnames: hosts,
63
+ enabled,
64
+ injection: cf.auto_install ? 'provider-auto' : 'sveltekit-hook',
65
+ createdAt: cf.created
66
+ };
67
+ }
68
+
69
+ class CloudflareRumImplementation implements RumImplementation {
70
+ readonly id = 'cloudflare-web-analytics';
71
+ readonly descriptor: CloudflareRumDescriptor;
72
+ private creds: { accountId: string; apiToken: string } | null = null;
73
+
74
+ constructor(descriptor: CloudflareRumDescriptor) {
75
+ this.descriptor = CloudflareRumDescriptorSchema.parse(descriptor);
76
+ }
77
+
78
+ private async getCreds(): Promise<{ accountId: string; apiToken: string }> {
79
+ if (this.creds) return this.creds;
80
+ const env = this.descriptor.environment || 'local';
81
+ const ac = (this.descriptor.adapterConfig ?? {}) as Record<string, unknown>;
82
+ const accountKey =
83
+ typeof ac.accountIdEnvVar === 'string' ? ac.accountIdEnvVar : 'CLOUDFLARE_ACCOUNT_ID';
84
+ const primaryToken =
85
+ typeof ac.apiTokenEnvVar === 'string' ? ac.apiTokenEnvVar : 'CLOUDFLARE_API_TOKEN';
86
+ const accountRes = await resolveAdapterCredential({
87
+ keyCandidates: [accountKey],
88
+ environment: env,
89
+ humanName: 'Cloudflare account id'
90
+ });
91
+ const tokenRes = await resolveAdapterCredential({
92
+ keyCandidates: [primaryToken],
93
+ environment: env,
94
+ humanName: 'Cloudflare API token (Account RUM: Edit scope for write ops)'
95
+ });
96
+ this.creds = { accountId: accountRes.value, apiToken: tokenRes.value };
97
+ return this.creds;
98
+ }
99
+
100
+ private async call<T>(path: string, init?: RequestInit): Promise<CfApiBody<T>> {
101
+ const { apiToken } = await this.getCreds();
102
+ const res = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
103
+ ...init,
104
+ headers: {
105
+ Authorization: `Bearer ${apiToken}`,
106
+ 'Content-Type': 'application/json',
107
+ ...(init?.headers ?? {})
108
+ }
109
+ });
110
+ const text = await res.text();
111
+ let body: CfApiBody<T>;
112
+ try {
113
+ body = JSON.parse(text) as CfApiBody<T>;
114
+ } catch {
115
+ throw new Error(
116
+ `cloudflare-web-analytics: non-JSON response from ${path}: ${text.slice(0, 200)}`
117
+ );
118
+ }
119
+ if (!res.ok || body.success === false) {
120
+ const errSummary = body.errors?.map((e) => `${e.code}: ${e.message}`).join('; ') ?? text.slice(0, 200);
121
+ throw new Error(`cloudflare-web-analytics: ${path} → ${res.status} ${errSummary}`);
122
+ }
123
+ return body;
124
+ }
125
+
126
+ async listSites(): Promise<RumSiteSnapshot[]> {
127
+ const { accountId } = await this.getCreds();
128
+ const body = await this.call<CfRumSite[]>(
129
+ `/accounts/${accountId}/rum/site_info/list?per_page=50`
130
+ );
131
+ return (body.result ?? []).map(fromCfSite);
132
+ }
133
+
134
+ async getSite(siteTag: string): Promise<RumSiteSnapshot | null> {
135
+ const { accountId } = await this.getCreds();
136
+ try {
137
+ const body = await this.call<CfRumSite>(`/accounts/${accountId}/rum/site_info/${siteTag}`);
138
+ return fromCfSite(body.result);
139
+ } catch (err) {
140
+ const msg = err instanceof Error ? err.message : String(err);
141
+ if (msg.includes('404') || msg.toLowerCase().includes('not found')) return null;
142
+ throw err;
143
+ }
144
+ }
145
+
146
+ async ensureSite(descriptor: InfraRumDescriptor): Promise<{ siteTag: string; created: boolean }> {
147
+ const { accountId } = await this.getCreds();
148
+ // If the descriptor already names an explicit siteToken (string),
149
+ // treat that as the existing siteTag and just return it. CF Web
150
+ // Analytics rotates tags only on explicit re-creation.
151
+ const declaredToken =
152
+ typeof descriptor.siteToken === 'string' ? descriptor.siteToken : undefined;
153
+ if (declaredToken) {
154
+ const live = await this.getSite(declaredToken);
155
+ if (live) return { siteTag: live.siteTag, created: false };
156
+ }
157
+
158
+ // Otherwise look for an existing site matching one of the declared hostnames.
159
+ const all = await this.listSites();
160
+ const match = all.find((s) => descriptor.hostnames.some((h) => s.hostnames.includes(h)));
161
+ if (match) return { siteTag: match.siteTag, created: false };
162
+
163
+ // Create a new site.
164
+ const payload = {
165
+ host: descriptor.hostnames[0] ?? '*',
166
+ auto_install: descriptor.injection === 'provider-auto'
167
+ };
168
+ const body = await this.call<CfRumSite>(`/accounts/${accountId}/rum/site_info`, {
169
+ method: 'POST',
170
+ body: JSON.stringify(payload)
171
+ });
172
+ return { siteTag: body.result.site_tag, created: true };
173
+ }
174
+
175
+ async setEnabled(siteTag: string, enabled: boolean): Promise<void> {
176
+ const { accountId } = await this.getCreds();
177
+ // CF doesn't expose a single "enabled" toggle — it pauses rules
178
+ // instead. We touch the first rule per the dashboard pattern.
179
+ const current = await this.call<CfRumSite>(`/accounts/${accountId}/rum/site_info/${siteTag}`);
180
+ const rules = (current.result.rules ?? []).map((r) => ({ ...r, is_paused: !enabled }));
181
+ await this.call<unknown>(`/accounts/${accountId}/rum/site_info/${siteTag}`, {
182
+ method: 'PATCH',
183
+ body: JSON.stringify({ rules })
184
+ });
185
+ }
186
+ }
187
+
188
+ export function createCloudflareRumImplementation(
189
+ input: CloudflareRumDescriptorInput
190
+ ): RumImplementation {
191
+ return new CloudflareRumImplementation(CloudflareRumDescriptorSchema.parse(input));
192
+ }
@@ -0,0 +1,494 @@
1
+ /**
2
+ * Cloudflare Web App (Workers + Static Assets) Implementation
3
+ *
4
+ * Generates the COMPLETE wrangler.jsonc shape for SvelteKit (and other SSR-asset)
5
+ * apps that deploy as a Cloudflare Worker with a Static Assets binding —
6
+ * the production target for every web app in this monorepo.
7
+ *
8
+ * `createCloudflareWebAppDeployment({ ... })` takes the provider-agnostic
9
+ * `AppDeployment` plus every Cloudflare-specific config group, and emits a
10
+ * `WranglerWebAppConfig` covering every wrangler key currently in use across
11
+ * the repo: `name`, `main`, `compatibility_date`, `compatibility_flags`,
12
+ * `assets`, `routes`, `placement`, `vars`, `secrets_store_secrets`, `secrets.required`,
13
+ * `d1_databases`, `r2_buckets`, `services`, `observability`, `triggers`,
14
+ * `queues`, `durable_objects`, `migrations`, `send_email`, `workers_dev`,
15
+ * `preview_urls`.
16
+ *
17
+ * The output of `generateWranglerConfig()` is the authoritative shape;
18
+ * `apps/<name>/wrangler.jsonc` is a build artifact written by
19
+ * `vibes infra deploy regenerate` (the sole supported path) from each app's
20
+ * `deployment.config.ts`. Drift is caught by the CLI with `--check`.
21
+ */
22
+
23
+ import * as z from 'zod/v4';
24
+ import type { AppDeployment } from '@vibesdotdev/infra-core/deployment';
25
+
26
+ export const CloudflareWebAppDescriptorSchema = z.object({
27
+ kind: z.literal('infra/web-app'),
28
+ id: z.string().min(1),
29
+ name: z.string().min(1),
30
+ adapter: z.literal('cloudflare-workers'),
31
+ /** Cloudflare Workers script name (e.g., "vibes-ai") */
32
+ cfWorkerName: z.string().min(1),
33
+ /** Build output directory consumed by both `main` and `assets.directory` */
34
+ outputDir: z.string().min(1),
35
+ /** Path to the `_worker.js` entry point relative to `outputDir` */
36
+ workerEntry: z.string().min(1).default('_worker.js'),
37
+ /** Compatibility date for wrangler */
38
+ compatibilityDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
39
+ /** Compatibility flags for wrangler */
40
+ compatibilityFlags: z.array(z.string()).default(['nodejs_compat']),
41
+ /** Environment variables to include */
42
+ env: z.record(z.string(), z.string()).default({}),
43
+ /**
44
+ * Account-level Cloudflare Secrets Store ID. When set, secret env vars
45
+ * emit as `secrets_store_secrets[]` bindings; otherwise the generator
46
+ * falls back to `secrets.required[]` for deploy-time validation.
47
+ */
48
+ secretsStoreId: z.string().min(1).optional()
49
+ });
50
+
51
+ export type CloudflareWebAppDescriptor = z.infer<typeof CloudflareWebAppDescriptorSchema>;
52
+ export type CloudflareWebAppDescriptorInput = z.input<typeof CloudflareWebAppDescriptorSchema>;
53
+
54
+ export interface WranglerSecretsStoreBinding {
55
+ binding: string;
56
+ store_id: string;
57
+ secret_name: string;
58
+ }
59
+
60
+ export interface WranglerWebAppRoute {
61
+ pattern: string;
62
+ custom_domain?: boolean;
63
+ }
64
+
65
+ export interface WranglerWebAppAssets {
66
+ directory: string;
67
+ binding?: string;
68
+ not_found_handling?: 'none' | 'single-page-application' | '404-page';
69
+ run_worker_first?: boolean | string[];
70
+ }
71
+
72
+ export interface WranglerD1Database {
73
+ binding: string;
74
+ database_name: string;
75
+ database_id?: string;
76
+ migrations_dir?: string;
77
+ }
78
+
79
+ export interface WranglerR2Bucket {
80
+ binding: string;
81
+ bucket_name: string;
82
+ }
83
+
84
+ export interface WranglerServiceBinding {
85
+ binding: string;
86
+ service: string;
87
+ entrypoint?: string;
88
+ }
89
+
90
+ export interface WranglerObservability {
91
+ enabled: boolean;
92
+ head_sampling_rate?: number;
93
+ }
94
+
95
+ export interface WranglerAiBinding {
96
+ binding: string;
97
+ staging?: boolean;
98
+ remote?: boolean;
99
+ }
100
+
101
+ export interface WranglerPlacement {
102
+ mode: 'smart';
103
+ }
104
+
105
+ export interface WranglerTriggers {
106
+ crons: string[];
107
+ }
108
+
109
+ export interface WranglerQueueProducer {
110
+ queue: string;
111
+ binding: string;
112
+ }
113
+
114
+ export interface WranglerQueueConsumer {
115
+ queue: string;
116
+ max_batch_size?: number;
117
+ max_batch_timeout?: number;
118
+ max_retries?: number;
119
+ dead_letter_queue?: string;
120
+ }
121
+
122
+ export interface WranglerQueues {
123
+ producers?: WranglerQueueProducer[];
124
+ consumers?: WranglerQueueConsumer[];
125
+ }
126
+
127
+ export interface WranglerDurableObjectBinding {
128
+ name: string;
129
+ class_name: string;
130
+ script_name?: string;
131
+ environment?: string;
132
+ }
133
+
134
+ export interface WranglerDurableObjects {
135
+ bindings: WranglerDurableObjectBinding[];
136
+ }
137
+
138
+ export interface WranglerMigration {
139
+ tag: string;
140
+ new_classes?: string[];
141
+ new_sqlite_classes?: string[];
142
+ deleted_classes?: string[];
143
+ renamed_classes?: Array<{ from: string; to: string }>;
144
+ transferred_classes?: Array<{ from: string; from_script: string; to: string }>;
145
+ }
146
+
147
+ export interface WranglerSendEmailBinding {
148
+ name: string;
149
+ destination_address?: string;
150
+ allowed_destination_addresses?: string[];
151
+ }
152
+
153
+ export interface WranglerAnalyticsEngineBinding {
154
+ binding: string;
155
+ dataset: string;
156
+ }
157
+
158
+ export interface WranglerWebAppConfig {
159
+ $schema?: string;
160
+ name: string;
161
+ /** Worker entry. Omitted only for `staticAssetsOnly` configs (e.g. apps/docs). */
162
+ main?: string;
163
+ compatibility_date: string;
164
+ compatibility_flags: string[];
165
+ workers_dev?: boolean;
166
+ preview_urls?: boolean;
167
+ upload_source_maps?: boolean;
168
+ observability?: WranglerObservability;
169
+ placement?: WranglerPlacement;
170
+ /** Always present: every CF Worker in this repo binds static assets. */
171
+ assets: WranglerWebAppAssets;
172
+ routes?: WranglerWebAppRoute[];
173
+ vars?: Record<string, string>;
174
+ secrets_store_secrets?: WranglerSecretsStoreBinding[];
175
+ secrets?: { required: string[] };
176
+ d1_databases?: WranglerD1Database[];
177
+ kv_namespaces?: Array<{
178
+ binding: string;
179
+ id: string;
180
+ preview_id?: string;
181
+ }>;
182
+ r2_buckets?: WranglerR2Bucket[];
183
+ services?: WranglerServiceBinding[];
184
+ ai?: WranglerAiBinding;
185
+ triggers?: WranglerTriggers;
186
+ queues?: WranglerQueues;
187
+ durable_objects?: WranglerDurableObjects;
188
+ migrations?: WranglerMigration[];
189
+ send_email?: WranglerSendEmailBinding[];
190
+ analytics_engine_datasets?: WranglerAnalyticsEngineBinding[];
191
+ /** esbuild/wrangler bundler rules (e.g. to treat leaked .svelte icon files from @lucide/svelte as text). */
192
+ rules?: Array<{
193
+ type: string;
194
+ globs: string[];
195
+ fallthrough?: boolean;
196
+ }>;
197
+ }
198
+
199
+ /**
200
+ * Shared Workers Analytics Engine dataset used for per-org bandwidth
201
+ * attribution. Bound automatically into every customer-facing Worker so
202
+ * the `billing.bandwidth-sampler` job has a uniform write target. Apps
203
+ * that need to opt out can pass `analyticsEngineDatasets: []`.
204
+ */
205
+ export const SHARED_BANDWIDTH_TELEMETRY_DATASET: WranglerAnalyticsEngineBinding = {
206
+ binding: 'BANDWIDTH_TELEMETRY',
207
+ dataset: 'bandwidth_egress'
208
+ };
209
+
210
+ /**
211
+ * Provider-agnostic cloudflare-side configuration for `createCloudflareWebAppDeployment`.
212
+ * Every field is optional; the generator emits only what the app declares so
213
+ * existing apps that don't (e.g.) bind R2 don't get an empty `r2_buckets: []`
214
+ * key in their wrangler.jsonc.
215
+ */
216
+ export interface CloudflareWebAppOptions {
217
+ workerName: string;
218
+ deployment: AppDeployment;
219
+ compatibilityDate?: string;
220
+ compatibilityFlags?: string[];
221
+ /** When set, secret env vars are emitted as `secrets_store_secrets[]`. */
222
+ secretsStoreId?: string;
223
+ /** `_worker.js`-relative path inside outputDir; defaults to `_worker.js`. */
224
+ workerEntry?: string;
225
+ /** Override `assets.binding`; defaults to `ASSETS`. */
226
+ assetsBinding?: string;
227
+ /** Override `assets.not_found_handling`; defaults to `'none'`. */
228
+ assetsNotFoundHandling?: 'none' | 'single-page-application' | '404-page';
229
+ /** Override `assets.run_worker_first`; defaults to `true` for SSR adapters. */
230
+ assetsRunWorkerFirst?: boolean | string[];
231
+ /** Omit `main` and emit a static-assets-only Worker (used for `apps/docs`). */
232
+ staticAssetsOnly?: boolean;
233
+ /** `workers_dev` flag — defaults to omitted (CF default behavior). */
234
+ workersDev?: boolean;
235
+ /** `preview_urls` flag — defaults to omitted (CF default behavior). */
236
+ previewUrls?: boolean;
237
+ /** Observability config; pass `{ enabled: true }` to emit `observability: { enabled: true }`. */
238
+ observability?: WranglerObservability;
239
+ /** Placement config; use `{ mode: 'smart' }` for Cloudflare Smart Placement. */
240
+ placement?: WranglerPlacement;
241
+ /** Convenience flag for `placement: { mode: 'smart' }`. */
242
+ smartPlacement?: boolean;
243
+ /** D1 database bindings. */
244
+ d1Databases?: WranglerD1Database[];
245
+ /** KV namespace bindings. */
246
+ kvNamespaces?: Array<{
247
+ binding: string;
248
+ id: string;
249
+ preview_id?: string;
250
+ }>;
251
+ /** R2 bucket bindings. */
252
+ r2Buckets?: WranglerR2Bucket[];
253
+ /** Worker-to-Worker Service Bindings (e.g. `AUTH_SERVICE` → `vibes-auth`). */
254
+ services?: WranglerServiceBinding[];
255
+ /** Cloudflare Workers AI binding. */
256
+ ai?: WranglerAiBinding;
257
+ /** `triggers.crons` cron expressions. */
258
+ crons?: string[];
259
+ /** CF Queue producer bindings. */
260
+ queueProducers?: WranglerQueueProducer[];
261
+ /** CF Queue consumer configs (declared on the consuming Worker). */
262
+ queueConsumers?: WranglerQueueConsumer[];
263
+ /** Durable Object class bindings. */
264
+ durableObjects?: WranglerDurableObjectBinding[];
265
+ /** DO migration tags (required when declaring `durableObjects`). */
266
+ migrations?: WranglerMigration[];
267
+ /** `send_email[]` bindings for the Cloudflare Email Workers API. */
268
+ sendEmail?: WranglerSendEmailBinding[];
269
+ /**
270
+ * Workers Analytics Engine dataset bindings. Used for per-org bandwidth
271
+ * attribution by `@vibesdotdev/cloudflare-bandwidth-telemetry` (see
272
+ * `billing.bandwidth-sampler`). All customer-facing Workers in this
273
+ * monorepo share the same `bandwidth_egress` dataset.
274
+ */
275
+ analyticsEngineDatasets?: WranglerAnalyticsEngineBinding[];
276
+ /**
277
+ * When `true` (default), this deployment is managed by the Vibes infra system
278
+ * and wrangler.jsonc should be regenerated from the deployment config.
279
+ * Set to `false` to opt out of automatic wrangler.jsonc regeneration.
280
+ */
281
+ managed?: boolean;
282
+ /**
283
+ * When `true`, run `wrangler d1 migrations apply` for each declared D1 database
284
+ * with a `migrations_dir` before deploying the Worker. Defaults to `false`.
285
+ */
286
+ autoMigrateD1?: boolean;
287
+ /**
288
+ * When `true`, upload source maps alongside the Worker for better production
289
+ * stack traces. Defaults to `true` for all managed deployments.
290
+ */
291
+ uploadSourceMaps?: boolean;
292
+ /** Additional wrangler/esbuild rules (e.g. to treat third-party .svelte icon files as text so wrangler bundling doesn't fail). */
293
+ rules?: Array<{ type: string; globs: string[]; fallthrough?: boolean }>;
294
+ }
295
+
296
+ export interface CloudflareWebAppDeployment {
297
+ workerName: string;
298
+ deployment: AppDeployment;
299
+ compatibilityDate: string;
300
+ /**
301
+ * When `true`, this deployment is managed by the Vibes infra system
302
+ * and wrangler.jsonc should be regenerated from the deployment config.
303
+ * Defaults to `true` for all Cloudflare web app deployments.
304
+ */
305
+ managed: boolean;
306
+ /**
307
+ * When `true`, run D1 migrations before deploying the Worker.
308
+ */
309
+ autoMigrateD1: boolean;
310
+ generateWranglerConfig(): WranglerWebAppConfig;
311
+ }
312
+
313
+ /**
314
+ * Create a Cloudflare Workers + Static Assets deployment wrapper.
315
+ */
316
+ export function createCloudflareWebAppDeployment(
317
+ options: CloudflareWebAppOptions
318
+ ): CloudflareWebAppDeployment {
319
+ const {
320
+ workerName,
321
+ deployment,
322
+ compatibilityDate,
323
+ compatibilityFlags = ['nodejs_compat'],
324
+ secretsStoreId,
325
+ workerEntry = '_worker.js',
326
+ assetsBinding = 'ASSETS',
327
+ assetsNotFoundHandling = 'none',
328
+ assetsRunWorkerFirst = true,
329
+ staticAssetsOnly = false,
330
+ workersDev,
331
+ previewUrls,
332
+ observability,
333
+ placement,
334
+ smartPlacement,
335
+ d1Databases,
336
+ kvNamespaces,
337
+ r2Buckets,
338
+ services,
339
+ ai,
340
+ crons,
341
+ queueProducers,
342
+ queueConsumers,
343
+ durableObjects,
344
+ migrations,
345
+ sendEmail,
346
+ analyticsEngineDatasets,
347
+ managed = true,
348
+ autoMigrateD1 = false,
349
+ uploadSourceMaps = true,
350
+ rules
351
+ } = options;
352
+
353
+ if (kvNamespaces?.length) (deployment as Record<string, unknown>).kvNamespaces = kvNamespaces;
354
+ const compatDate = compatibilityDate ?? new Date().toISOString().slice(0, 10);
355
+
356
+ return {
357
+ workerName,
358
+ deployment,
359
+ compatibilityDate: compatDate,
360
+ managed,
361
+ autoMigrateD1,
362
+
363
+ generateWranglerConfig(): WranglerWebAppConfig {
364
+ // wrangler.jsonc paths are resolved relative to the directory wrangler
365
+ // runs from. CI / infra-deploy both invoke `wrangler deploy --cwd <appDir>`,
366
+ // so the assets directory and `main` must be APP-relative even though
367
+ // `deployment.build.outputDir` is workspace-relative (that form is used
368
+ // by the build command and other consumers).
369
+ const rawOutputDir = deployment.build.outputDir.replace(/\/$/, '');
370
+ const appDir = deployment.build.appDir.replace(/\/$/, '');
371
+ const outputDir = rawOutputDir.startsWith(`${appDir}/`)
372
+ ? rawOutputDir.slice(appDir.length + 1)
373
+ : rawOutputDir;
374
+ const assets: WranglerWebAppAssets = staticAssetsOnly
375
+ ? {
376
+ directory: outputDir,
377
+ not_found_handling: assetsNotFoundHandling
378
+ }
379
+ : {
380
+ directory: outputDir,
381
+ binding: assetsBinding,
382
+ not_found_handling: assetsNotFoundHandling,
383
+ run_worker_first: assetsRunWorkerFirst
384
+ };
385
+
386
+ const config: WranglerWebAppConfig = {
387
+ $schema: 'node_modules/wrangler/config-schema.json',
388
+ name: workerName,
389
+ compatibility_date: compatDate,
390
+ compatibility_flags: compatibilityFlags,
391
+ assets
392
+ };
393
+
394
+ if (!staticAssetsOnly) {
395
+ config.main = `${outputDir}/${workerEntry}`;
396
+ }
397
+
398
+ if (workersDev !== undefined) config.workers_dev = workersDev;
399
+ if (previewUrls !== undefined) config.preview_urls = previewUrls;
400
+ if (uploadSourceMaps) config.upload_source_maps = true;
401
+ if (observability) config.observability = observability;
402
+ if (placement) config.placement = placement;
403
+ else if (smartPlacement) config.placement = { mode: 'smart' };
404
+
405
+ const primaryRoutes = deployment.origins
406
+ .filter((o) => o.kind === 'primary')
407
+ .map((o) => ({ pattern: o.hostname, custom_domain: true }));
408
+ if (primaryRoutes.length > 0) {
409
+ config.routes = primaryRoutes;
410
+ }
411
+
412
+ const plainEnvVars: Record<string, string> = {};
413
+ for (const envVar of deployment.env) {
414
+ if (!envVar.secret && envVar.value !== undefined) {
415
+ plainEnvVars[envVar.name] = envVar.value;
416
+ }
417
+ }
418
+ if (Object.keys(plainEnvVars).length > 0) {
419
+ config.vars = plainEnvVars;
420
+ }
421
+
422
+ const secretEnvVars = deployment.env
423
+ .filter((envVar) => envVar.secret)
424
+ .sort((a, b) => a.name.localeCompare(b.name));
425
+
426
+ if (secretEnvVars.length > 0) {
427
+ if (secretsStoreId) {
428
+ config.secrets_store_secrets = secretEnvVars
429
+ .map((envVar) => ({
430
+ binding: envVar.name,
431
+ store_id: secretsStoreId,
432
+ secret_name: envVar.storeKey ?? envVar.name
433
+ }));
434
+ } else {
435
+ config.secrets = {
436
+ required: secretEnvVars
437
+ .filter((envVar) => envVar.required !== false)
438
+ .map((envVar) => envVar.name)
439
+ };
440
+ }
441
+ }
442
+
443
+ if (d1Databases?.length) config.d1_databases = d1Databases;
444
+ if (kvNamespaces?.length) config.kv_namespaces = kvNamespaces;
445
+ if (r2Buckets?.length) config.r2_buckets = r2Buckets;
446
+ if (services?.length) config.services = services;
447
+ if (ai) config.ai = ai;
448
+ if (crons?.length) config.triggers = { crons };
449
+ if (queueProducers?.length || queueConsumers?.length) {
450
+ config.queues = {};
451
+ if (queueProducers?.length) config.queues.producers = queueProducers;
452
+ if (queueConsumers?.length) config.queues.consumers = queueConsumers;
453
+ }
454
+ if (durableObjects?.length) {
455
+ config.durable_objects = { bindings: durableObjects };
456
+ }
457
+ if (migrations?.length) config.migrations = migrations;
458
+ if (sendEmail?.length) config.send_email = sendEmail;
459
+ // WAE binding default: every customer-facing Worker gets
460
+ // BANDWIDTH_TELEMETRY → bandwidth_egress so the
461
+ // billing.bandwidth-sampler aggregator can attribute bytes per
462
+ // org. Opt out by passing `analyticsEngineDatasets: []`.
463
+ const resolvedAnalyticsBindings =
464
+ analyticsEngineDatasets ?? [SHARED_BANDWIDTH_TELEMETRY_DATASET];
465
+ if (resolvedAnalyticsBindings.length > 0) {
466
+ config.analytics_engine_datasets = resolvedAnalyticsBindings;
467
+ }
468
+ if (rules?.length) (config as any).rules = rules;
469
+
470
+ return config;
471
+ }
472
+ };
473
+ }
474
+
475
+ /**
476
+ * Generate a Workers Static Assets descriptor from an existing AppDeployment.
477
+ */
478
+ export function generateCloudflareWebAppDescriptor(
479
+ deployment: AppDeployment,
480
+ cfWorkerName: string
481
+ ): CloudflareWebAppDescriptor {
482
+ return CloudflareWebAppDescriptorSchema.parse({
483
+ kind: 'infra/web-app',
484
+ id: deployment.appId,
485
+ name: deployment.appName,
486
+ adapter: 'cloudflare-workers',
487
+ cfWorkerName,
488
+ outputDir: deployment.build.outputDir,
489
+ env: deployment.env.reduce((acc, e) => {
490
+ if (e.value) acc[e.name] = e.value;
491
+ return acc;
492
+ }, {} as Record<string, string>)
493
+ });
494
+ }