@emdash-cms/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 (67) hide show
  1. package/dist/auth/index.d.mts +81 -0
  2. package/dist/auth/index.mjs +147 -0
  3. package/dist/cache/config.d.mts +52 -0
  4. package/dist/cache/config.mjs +55 -0
  5. package/dist/cache/runtime.d.mts +40 -0
  6. package/dist/cache/runtime.mjs +191 -0
  7. package/dist/d1-introspector-bZf0_ylK.mjs +57 -0
  8. package/dist/db/d1.d.mts +43 -0
  9. package/dist/db/d1.mjs +74 -0
  10. package/dist/db/do.d.mts +96 -0
  11. package/dist/db/do.mjs +489 -0
  12. package/dist/db/playground-middleware.d.mts +20 -0
  13. package/dist/db/playground-middleware.mjs +533 -0
  14. package/dist/db/playground.d.mts +39 -0
  15. package/dist/db/playground.mjs +26 -0
  16. package/dist/do-class-DY2Ba2RJ.mjs +174 -0
  17. package/dist/do-class-x5Xh_G62.d.mts +73 -0
  18. package/dist/do-dialect-BhFcRSFQ.mjs +58 -0
  19. package/dist/do-playground-routes-CmwFeGwJ.mjs +49 -0
  20. package/dist/do-types-CY0G0oyh.d.mts +14 -0
  21. package/dist/images-4RT9Ag8_.d.mts +76 -0
  22. package/dist/index.d.mts +200 -0
  23. package/dist/index.mjs +214 -0
  24. package/dist/media/images-runtime.d.mts +10 -0
  25. package/dist/media/images-runtime.mjs +215 -0
  26. package/dist/media/stream-runtime.d.mts +10 -0
  27. package/dist/media/stream-runtime.mjs +218 -0
  28. package/dist/plugins/index.d.mts +32 -0
  29. package/dist/plugins/index.mjs +163 -0
  30. package/dist/sandbox/index.d.mts +255 -0
  31. package/dist/sandbox/index.mjs +945 -0
  32. package/dist/storage/r2.d.mts +31 -0
  33. package/dist/storage/r2.mjs +116 -0
  34. package/dist/stream-DdbcvKi0.d.mts +78 -0
  35. package/package.json +109 -0
  36. package/src/auth/cloudflare-access.ts +303 -0
  37. package/src/auth/index.ts +16 -0
  38. package/src/cache/config.ts +81 -0
  39. package/src/cache/runtime.ts +328 -0
  40. package/src/cloudflare.d.ts +31 -0
  41. package/src/db/d1-introspector.ts +120 -0
  42. package/src/db/d1.ts +112 -0
  43. package/src/db/do-class.ts +275 -0
  44. package/src/db/do-dialect.ts +125 -0
  45. package/src/db/do-playground-routes.ts +65 -0
  46. package/src/db/do-preview-routes.ts +48 -0
  47. package/src/db/do-preview-sign.ts +100 -0
  48. package/src/db/do-preview.ts +268 -0
  49. package/src/db/do-types.ts +12 -0
  50. package/src/db/do.ts +62 -0
  51. package/src/db/playground-middleware.ts +340 -0
  52. package/src/db/playground-toolbar.ts +341 -0
  53. package/src/db/playground.ts +49 -0
  54. package/src/db/preview-toolbar.ts +220 -0
  55. package/src/index.ts +285 -0
  56. package/src/media/images-runtime.ts +353 -0
  57. package/src/media/images.ts +114 -0
  58. package/src/media/stream-runtime.ts +392 -0
  59. package/src/media/stream.ts +118 -0
  60. package/src/plugins/index.ts +7 -0
  61. package/src/plugins/vectorize-search.ts +393 -0
  62. package/src/sandbox/bridge.ts +1008 -0
  63. package/src/sandbox/index.ts +13 -0
  64. package/src/sandbox/runner.ts +357 -0
  65. package/src/sandbox/types.ts +181 -0
  66. package/src/sandbox/wrapper.ts +238 -0
  67. package/src/storage/r2.ts +200 -0
package/src/index.ts ADDED
@@ -0,0 +1,285 @@
1
+ /**
2
+ * @emdash-cms/cloudflare
3
+ *
4
+ * Cloudflare adapters for EmDash:
5
+ * - D1 database adapter
6
+ * - R2 storage adapter
7
+ * - Cloudflare Access authentication
8
+ * - Worker Loader sandbox for plugins
9
+ *
10
+ * This is the CONFIG-TIME entry point. It does NOT import cloudflare:workers
11
+ * and is safe to use in astro.config.mjs.
12
+ *
13
+ * For runtime exports (PluginBridge, authenticate), import from the specific
14
+ * runtime entrypoints:
15
+ * - @emdash-cms/cloudflare/sandbox (PluginBridge, createSandboxRunner)
16
+ * - @emdash-cms/cloudflare/auth (authenticate)
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * import emdash from "emdash/astro";
21
+ * import { d1, r2, access, sandbox } from "@emdash-cms/cloudflare";
22
+ *
23
+ * export default defineConfig({
24
+ * integrations: [
25
+ * emdash({
26
+ * database: d1({ binding: "DB" }),
27
+ * storage: r2({ binding: "MEDIA" }),
28
+ * auth: access({ teamDomain: "myteam.cloudflareaccess.com" }),
29
+ * sandboxRunner: sandbox(),
30
+ * }),
31
+ * ],
32
+ * });
33
+ * ```
34
+ */
35
+
36
+ import type { AuthDescriptor, DatabaseDescriptor, StorageDescriptor } from "emdash";
37
+
38
+ import type { PreviewDOConfig } from "./db/do-types.js";
39
+
40
+ /**
41
+ * D1 configuration
42
+ */
43
+ export interface D1Config {
44
+ /**
45
+ * Name of the D1 binding in wrangler.toml
46
+ */
47
+ binding: string;
48
+
49
+ /**
50
+ * Read replication session mode.
51
+ *
52
+ * - `"disabled"` — No sessions. All queries go to primary. (default)
53
+ * - `"auto"` — Automatic session management. Anonymous requests use
54
+ * `"first-unconstrained"` (nearest replica). Authenticated requests
55
+ * use bookmark cookies for read-your-writes consistency.
56
+ * - `"primary-first"` — Like `"auto"`, but the first query in every
57
+ * session goes to the primary. Use this if your site has very
58
+ * frequent writes and you need stronger consistency guarantees
59
+ * at the cost of higher read latency.
60
+ *
61
+ * Read replication must also be enabled on the D1 database itself
62
+ * (via dashboard or REST API).
63
+ */
64
+ session?: "disabled" | "auto" | "primary-first";
65
+
66
+ /**
67
+ * Cookie name for storing the session bookmark.
68
+ * Only used when session is `"auto"` or `"primary-first"`.
69
+ *
70
+ * @default "__ec_d1_bookmark"
71
+ */
72
+ bookmarkCookie?: string;
73
+ }
74
+
75
+ /**
76
+ * R2 storage configuration
77
+ */
78
+ export interface R2StorageConfig {
79
+ /**
80
+ * Name of the R2 binding in wrangler.toml
81
+ */
82
+ binding: string;
83
+ /**
84
+ * Public URL for accessing files (optional CDN)
85
+ */
86
+ publicUrl?: string;
87
+ }
88
+
89
+ /**
90
+ * Configuration for Cloudflare Access authentication
91
+ */
92
+ export interface AccessConfig {
93
+ /**
94
+ * Your Cloudflare Access team domain
95
+ * @example "myteam.cloudflareaccess.com"
96
+ */
97
+ teamDomain: string;
98
+
99
+ /**
100
+ * Application Audience (AUD) tag from Access application settings.
101
+ * For Cloudflare Workers, use `audienceEnvVar` instead to read at runtime.
102
+ */
103
+ audience?: string;
104
+
105
+ /**
106
+ * Environment variable name containing the audience tag.
107
+ * Read at runtime from environment.
108
+ * @default "CF_ACCESS_AUDIENCE"
109
+ */
110
+ audienceEnvVar?: string;
111
+
112
+ /**
113
+ * Automatically create EmDash users on first login
114
+ * @default true
115
+ */
116
+ autoProvision?: boolean;
117
+
118
+ /**
119
+ * Role level for users not matching any group in roleMapping
120
+ * @default 30 (Editor)
121
+ */
122
+ defaultRole?: number;
123
+
124
+ /**
125
+ * Update user's role on each login based on current IdP groups
126
+ * When false, role is only set on first provisioning
127
+ * @default false
128
+ */
129
+ syncRoles?: boolean;
130
+
131
+ /**
132
+ * Map IdP group names to EmDash role levels
133
+ * First match wins if user is in multiple groups
134
+ *
135
+ * @example
136
+ * ```ts
137
+ * roleMapping: {
138
+ * "Admins": 50, // Admin
139
+ * "Developers": 40, // Developer
140
+ * "Content Team": 30, // Editor
141
+ * }
142
+ * ```
143
+ */
144
+ roleMapping?: Record<string, number>;
145
+ }
146
+
147
+ /**
148
+ * Cloudflare D1 database adapter
149
+ *
150
+ * For Cloudflare Workers with D1 binding.
151
+ * Migrations run automatically at setup time - no need for manual SQL files.
152
+ *
153
+ * Uses a custom introspector that works around D1's restriction on
154
+ * cross-joins with pragma_table_info().
155
+ *
156
+ * @example
157
+ * ```ts
158
+ * database: d1({ binding: "DB" })
159
+ * ```
160
+ */
161
+ export function d1(config: D1Config): DatabaseDescriptor {
162
+ return {
163
+ entrypoint: "@emdash-cms/cloudflare/db/d1",
164
+ config,
165
+ type: "sqlite",
166
+ };
167
+ }
168
+
169
+ export type { PreviewDOConfig } from "./db/do-types.js";
170
+
171
+ /**
172
+ * Durable Object preview database adapter
173
+ *
174
+ * Each preview session gets an isolated SQLite database inside a DO,
175
+ * populated from a snapshot of the source EmDash site.
176
+ *
177
+ * Not for production use — preview only.
178
+ *
179
+ * @example
180
+ * ```ts
181
+ * database: previewDatabase({ binding: "PREVIEW_DB" })
182
+ * ```
183
+ */
184
+ export function previewDatabase(config: PreviewDOConfig): DatabaseDescriptor {
185
+ return {
186
+ entrypoint: "@emdash-cms/cloudflare/db/do",
187
+ config,
188
+ type: "sqlite",
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Durable Object playground database adapter
194
+ *
195
+ * Each playground session gets an isolated SQLite database inside a DO,
196
+ * populated from a seed file with migrations run at init time.
197
+ * Unlike preview, playground is writable and has admin access.
198
+ *
199
+ * Not for production use -- playground/demo only.
200
+ *
201
+ * @example
202
+ * ```ts
203
+ * database: playgroundDatabase({ binding: "PLAYGROUND_DB" })
204
+ * ```
205
+ */
206
+ export function playgroundDatabase(config: PreviewDOConfig): DatabaseDescriptor {
207
+ return {
208
+ entrypoint: "@emdash-cms/cloudflare/db/playground",
209
+ config,
210
+ type: "sqlite",
211
+ };
212
+ }
213
+
214
+ /**
215
+ * Cloudflare R2 binding adapter
216
+ *
217
+ * Uses R2 bindings directly when running on Cloudflare Workers.
218
+ * Does NOT support signed upload URLs (use s3() with R2 credentials instead).
219
+ *
220
+ * Requires R2 binding in wrangler.toml:
221
+ * ```toml
222
+ * [[r2_buckets]]
223
+ * binding = "MEDIA"
224
+ * bucket_name = "my-media-bucket"
225
+ * ```
226
+ *
227
+ * @example
228
+ * ```ts
229
+ * storage: r2({ binding: "MEDIA" })
230
+ * ```
231
+ */
232
+ export function r2(config: R2StorageConfig): StorageDescriptor {
233
+ return {
234
+ entrypoint: "@emdash-cms/cloudflare/storage/r2",
235
+ config: { binding: config.binding, publicUrl: config.publicUrl },
236
+ };
237
+ }
238
+
239
+ /**
240
+ * Cloudflare Access authentication adapter
241
+ *
242
+ * Use this to configure EmDash to authenticate via Cloudflare Access.
243
+ * When Access is configured, passkey auth is disabled.
244
+ *
245
+ * @example
246
+ * ```ts
247
+ * auth: access({
248
+ * teamDomain: "myteam.cloudflareaccess.com",
249
+ * audience: "abc123...",
250
+ * roleMapping: {
251
+ * "Admins": 50,
252
+ * "Editors": 30,
253
+ * },
254
+ * })
255
+ * ```
256
+ */
257
+ export function access(config: AccessConfig): AuthDescriptor {
258
+ return {
259
+ type: "cloudflare-access",
260
+ entrypoint: "@emdash-cms/cloudflare/auth",
261
+ config,
262
+ };
263
+ }
264
+
265
+ /**
266
+ * Cloudflare Worker Loader sandbox adapter
267
+ *
268
+ * Returns the module path for the Cloudflare sandbox runner.
269
+ * Use this in the `sandboxRunner` config option.
270
+ *
271
+ * @example
272
+ * ```ts
273
+ * sandboxRunner: sandbox()
274
+ * ```
275
+ */
276
+ export function sandbox(): string {
277
+ return "@emdash-cms/cloudflare/sandbox";
278
+ }
279
+
280
+ // Re-export media providers (config-time)
281
+ export { cloudflareImages, type CloudflareImagesConfig } from "./media/images.js";
282
+ export { cloudflareStream, type CloudflareStreamConfig } from "./media/stream.js";
283
+
284
+ // Re-export cache provider config helper (config-time)
285
+ export { cloudflareCache, type CloudflareCacheConfig } from "./cache/config.js";
@@ -0,0 +1,353 @@
1
+ /**
2
+ * Cloudflare Images Runtime Module
3
+ *
4
+ * This module is imported at runtime by the media provider system.
5
+ * It contains the actual provider implementation that interacts with the Cloudflare API.
6
+ */
7
+
8
+ import { env } from "cloudflare:workers";
9
+ import type {
10
+ MediaProvider,
11
+ MediaListOptions,
12
+ MediaValue,
13
+ EmbedOptions,
14
+ EmbedResult,
15
+ CreateMediaProviderFn,
16
+ } from "emdash/media";
17
+
18
+ import type { CloudflareImagesConfig } from "./images.js";
19
+
20
+ /** Safely extract a number from an unknown value */
21
+ function toNumber(value: unknown): number | undefined {
22
+ return typeof value === "number" ? value : undefined;
23
+ }
24
+
25
+ /**
26
+ * Resolve a config value, checking env var if direct value not provided
27
+ */
28
+ function resolveEnvValue(
29
+ directValue: string | undefined,
30
+ envVarName: string | undefined,
31
+ defaultEnvVar: string,
32
+ serviceName: string,
33
+ ): string {
34
+ if (directValue) return directValue;
35
+ const envVar = envVarName || defaultEnvVar;
36
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Worker binding accessed from untyped env object
37
+ const value = (env as Record<string, string | undefined>)[envVar];
38
+ if (!value) {
39
+ throw new Error(
40
+ `${serviceName}: Missing ${envVar}. Set it as an environment variable or provide it directly in config.`,
41
+ );
42
+ }
43
+ return value;
44
+ }
45
+
46
+ /**
47
+ * Runtime implementation for Cloudflare Images provider
48
+ */
49
+ export const createMediaProvider: CreateMediaProviderFn<CloudflareImagesConfig> = (config) => {
50
+ const { deliveryDomain, defaultVariant = "public" } = config;
51
+
52
+ // Lazy getters - resolve env vars at request time, not module init time
53
+ const getAccountId = () =>
54
+ resolveEnvValue(config.accountId, config.accountIdEnvVar, "CF_ACCOUNT_ID", "Cloudflare Images");
55
+ const getAccountHash = () =>
56
+ resolveEnvValue(
57
+ config.accountHash,
58
+ config.accountHashEnvVar,
59
+ "CF_IMAGES_ACCOUNT_HASH",
60
+ "Cloudflare Images",
61
+ );
62
+ const getApiToken = () =>
63
+ resolveEnvValue(config.apiToken, config.apiTokenEnvVar, "CF_IMAGES_TOKEN", "Cloudflare Images");
64
+ const getApiBase = () =>
65
+ `https://api.cloudflare.com/client/v4/accounts/${getAccountId()}/images/v1`;
66
+ const getHeaders = () => ({ Authorization: `Bearer ${getApiToken()}` });
67
+ const getDeliveryBase = () =>
68
+ deliveryDomain ? `https://${deliveryDomain}` : "https://imagedelivery.net";
69
+
70
+ // Build a delivery URL with flexible variant transforms
71
+ const buildUrl = (imageId: string, transforms?: { w?: number; h?: number; fit?: string }) => {
72
+ const base = `${getDeliveryBase()}/${getAccountHash()}/${imageId}`;
73
+ if (!transforms || Object.keys(transforms).length === 0) {
74
+ return `${base}/${defaultVariant}`;
75
+ }
76
+ const parts: string[] = [];
77
+ if (transforms.w) parts.push(`w=${transforms.w}`);
78
+ if (transforms.h) parts.push(`h=${transforms.h}`);
79
+ if (transforms.fit) parts.push(`fit=${transforms.fit}`);
80
+ return `${base}/${parts.join(",")}`;
81
+ };
82
+
83
+ // Fetch image dimensions via the format=json delivery endpoint
84
+ // This is a public endpoint that doesn't require authentication
85
+ const fetchDimensions = async (
86
+ imageId: string,
87
+ ): Promise<{ width: number; height: number } | null> => {
88
+ const url = `${getDeliveryBase()}/${getAccountHash()}/${imageId}/format=json`;
89
+ try {
90
+ const response = await fetch(url);
91
+ if (!response.ok) return null;
92
+ const data: ImageJsonResponse = await response.json();
93
+ return { width: data.width, height: data.height };
94
+ } catch {
95
+ return null;
96
+ }
97
+ };
98
+
99
+ const provider: MediaProvider = {
100
+ async list(options: MediaListOptions) {
101
+ const apiBase = getApiBase();
102
+ const headers = getHeaders();
103
+
104
+ const params = new URLSearchParams();
105
+ if (options.cursor) {
106
+ params.set("continuation_token", options.cursor);
107
+ }
108
+ if (options.limit) {
109
+ params.set("per_page", String(options.limit));
110
+ }
111
+
112
+ const url = `${apiBase}?${params}`;
113
+ const response = await fetch(url, { headers });
114
+
115
+ if (!response.ok) {
116
+ throw new Error(`Cloudflare Images API error: ${response.status}`);
117
+ }
118
+
119
+ const data: CloudflareImagesListResponse = await response.json();
120
+
121
+ if (!data.success) {
122
+ throw new Error(
123
+ `Cloudflare Images API error: ${data.errors?.[0]?.message || "Unknown error"}`,
124
+ );
125
+ }
126
+
127
+ // Filter out images that require signed URLs (not supported yet)
128
+ const publicImages = data.result.images.filter((img) => !img.requireSignedURLs);
129
+
130
+ // Fetch dimensions for all images in parallel
131
+ const dimensionsMap = new Map<string, { width: number; height: number }>();
132
+ const dimensionResults = await Promise.all(
133
+ publicImages.map(async (img) => {
134
+ const dims = await fetchDimensions(img.id);
135
+ return { id: img.id, dims };
136
+ }),
137
+ );
138
+ for (const { id, dims } of dimensionResults) {
139
+ if (dims) dimensionsMap.set(id, dims);
140
+ }
141
+
142
+ return {
143
+ items: publicImages.map((img) => {
144
+ const dims = dimensionsMap.get(img.id);
145
+ return {
146
+ id: img.id,
147
+ filename: img.filename || img.id,
148
+ mimeType: "image/jpeg", // CF Images doesn't expose original mime type
149
+ width: dims?.width ?? toNumber(img.meta?.width),
150
+ height: dims?.height ?? toNumber(img.meta?.height),
151
+ // Use 400px wide preview for grid thumbnails (good for 2x retina on ~200px grid)
152
+ previewUrl: buildUrl(img.id, { w: 400, fit: "scale-down" }),
153
+ meta: {
154
+ variants: img.variants,
155
+ uploaded: img.uploaded,
156
+ },
157
+ };
158
+ }),
159
+ nextCursor: data.result.continuation_token || undefined,
160
+ };
161
+ },
162
+
163
+ async get(id: string) {
164
+ const apiBase = getApiBase();
165
+ const headers = getHeaders();
166
+
167
+ const url = `${apiBase}/${id}`;
168
+ const response = await fetch(url, { headers });
169
+
170
+ if (!response.ok) {
171
+ if (response.status === 404) return null;
172
+ throw new Error(`Cloudflare Images API error: ${response.status}`);
173
+ }
174
+
175
+ const data: CloudflareImageResponse = await response.json();
176
+
177
+ if (!data.success) {
178
+ return null;
179
+ }
180
+
181
+ const img = data.result;
182
+
183
+ // Don't return images that require signed URLs (not supported yet)
184
+ if (img.requireSignedURLs) {
185
+ return null;
186
+ }
187
+
188
+ // Fetch dimensions via format=json endpoint
189
+ const dims = await fetchDimensions(img.id);
190
+
191
+ return {
192
+ id: img.id,
193
+ filename: img.filename || img.id,
194
+ mimeType: "image/jpeg",
195
+ width: dims?.width ?? toNumber(img.meta?.width),
196
+ height: dims?.height ?? toNumber(img.meta?.height),
197
+ // Use larger preview for detail view
198
+ previewUrl: buildUrl(img.id, { w: 800, fit: "scale-down" }),
199
+ meta: {
200
+ variants: img.variants,
201
+ uploaded: img.uploaded,
202
+ },
203
+ };
204
+ },
205
+
206
+ async upload(input) {
207
+ const apiBase = getApiBase();
208
+ const apiToken = getApiToken();
209
+
210
+ const formData = new FormData();
211
+ formData.append("file", input.file, input.filename);
212
+
213
+ // Ensure uploaded images are public (don't require signed URLs)
214
+ formData.append("requireSignedURLs", "false");
215
+
216
+ // Add metadata if provided
217
+ const metadata: Record<string, string> = {};
218
+ if (input.alt) {
219
+ metadata.alt = input.alt;
220
+ }
221
+ if (Object.keys(metadata).length > 0) {
222
+ formData.append("metadata", JSON.stringify(metadata));
223
+ }
224
+
225
+ const response = await fetch(apiBase, {
226
+ method: "POST",
227
+ headers: {
228
+ Authorization: `Bearer ${apiToken}`,
229
+ // Don't set Content-Type - let browser set it with boundary
230
+ },
231
+ body: formData,
232
+ });
233
+
234
+ if (!response.ok) {
235
+ const error = await response.text();
236
+ throw new Error(`Cloudflare Images upload failed: ${error}`);
237
+ }
238
+
239
+ const data: CloudflareImageResponse = await response.json();
240
+
241
+ if (!data.success) {
242
+ throw new Error(
243
+ `Cloudflare Images upload failed: ${data.errors?.[0]?.message || "Unknown error"}`,
244
+ );
245
+ }
246
+
247
+ const img = data.result;
248
+ return {
249
+ id: img.id,
250
+ filename: img.filename || input.filename,
251
+ mimeType: "image/jpeg",
252
+ width: toNumber(img.meta?.width),
253
+ height: toNumber(img.meta?.height),
254
+ previewUrl: buildUrl(img.id, { w: 400, fit: "scale-down" }),
255
+ meta: {
256
+ variants: img.variants,
257
+ uploaded: img.uploaded,
258
+ },
259
+ };
260
+ },
261
+
262
+ async delete(id: string) {
263
+ const apiBase = getApiBase();
264
+ const headers = getHeaders();
265
+
266
+ const response = await fetch(`${apiBase}/${id}`, {
267
+ method: "DELETE",
268
+ headers,
269
+ });
270
+
271
+ if (!response.ok && response.status !== 404) {
272
+ throw new Error(`Cloudflare Images delete failed: ${response.status}`);
273
+ }
274
+ },
275
+
276
+ getEmbed(value: MediaValue, options?: EmbedOptions): EmbedResult {
277
+ const accountHash = getAccountHash();
278
+ const deliveryBase = getDeliveryBase();
279
+ const baseUrl = `${deliveryBase}/${accountHash}/${value.id}`;
280
+
281
+ // Helper to build URL with transforms
282
+ const buildSrc = (opts: { width?: number; height?: number; format?: string }) => {
283
+ const t: string[] = [];
284
+ if (opts.width) t.push(`w=${opts.width}`);
285
+ if (opts.height) t.push(`h=${opts.height}`);
286
+ if (opts.format) t.push(`f=${opts.format}`);
287
+ t.push("fit=scale-down");
288
+ return `${baseUrl}/${t.join(",")}`;
289
+ };
290
+
291
+ // Build src URL - always include transforms (CF Images requires a variant)
292
+ const width = options?.width ?? value.width ?? 1200;
293
+ const height = options?.height ?? value.height;
294
+ const src = buildSrc({ width, height, format: options?.format });
295
+
296
+ return {
297
+ type: "image",
298
+ src,
299
+ width: options?.width ?? value.width,
300
+ height: options?.height ?? value.height,
301
+ alt: value.alt,
302
+ // Provide getSrc for dynamic resizing (e.g., responsive images)
303
+ getSrc: buildSrc,
304
+ };
305
+ },
306
+
307
+ getThumbnailUrl(id: string, _mimeType?: string, options?: { width?: number; height?: number }) {
308
+ // For images, return a sized delivery URL
309
+ const width = options?.width || 400;
310
+ const height = options?.height;
311
+ return buildUrl(id, { w: width, h: height, fit: "scale-down" });
312
+ },
313
+ };
314
+
315
+ return provider;
316
+ };
317
+
318
+ // Cloudflare API response types
319
+ interface CloudflareImagesListResponse {
320
+ success: boolean;
321
+ errors?: Array<{ message: string }>;
322
+ result: {
323
+ images: CloudflareImage[];
324
+ continuation_token?: string;
325
+ };
326
+ }
327
+
328
+ interface CloudflareImageResponse {
329
+ success: boolean;
330
+ errors?: Array<{ message: string }>;
331
+ result: CloudflareImage;
332
+ }
333
+
334
+ interface CloudflareImage {
335
+ id: string;
336
+ filename?: string;
337
+ uploaded: string;
338
+ requireSignedURLs: boolean;
339
+ variants: string[];
340
+ meta?: Record<string, unknown>;
341
+ }
342
+
343
+ // Response from format=json delivery endpoint
344
+ interface ImageJsonResponse {
345
+ width: number;
346
+ height: number;
347
+ original: {
348
+ file_size: number;
349
+ width: number;
350
+ height: number;
351
+ format: string;
352
+ };
353
+ }