@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.
- package/dist/auth/index.d.mts +81 -0
- package/dist/auth/index.mjs +147 -0
- package/dist/cache/config.d.mts +52 -0
- package/dist/cache/config.mjs +55 -0
- package/dist/cache/runtime.d.mts +40 -0
- package/dist/cache/runtime.mjs +191 -0
- package/dist/d1-introspector-bZf0_ylK.mjs +57 -0
- package/dist/db/d1.d.mts +43 -0
- package/dist/db/d1.mjs +74 -0
- package/dist/db/do.d.mts +96 -0
- package/dist/db/do.mjs +489 -0
- package/dist/db/playground-middleware.d.mts +20 -0
- package/dist/db/playground-middleware.mjs +533 -0
- package/dist/db/playground.d.mts +39 -0
- package/dist/db/playground.mjs +26 -0
- package/dist/do-class-DY2Ba2RJ.mjs +174 -0
- package/dist/do-class-x5Xh_G62.d.mts +73 -0
- package/dist/do-dialect-BhFcRSFQ.mjs +58 -0
- package/dist/do-playground-routes-CmwFeGwJ.mjs +49 -0
- package/dist/do-types-CY0G0oyh.d.mts +14 -0
- package/dist/images-4RT9Ag8_.d.mts +76 -0
- package/dist/index.d.mts +200 -0
- package/dist/index.mjs +214 -0
- package/dist/media/images-runtime.d.mts +10 -0
- package/dist/media/images-runtime.mjs +215 -0
- package/dist/media/stream-runtime.d.mts +10 -0
- package/dist/media/stream-runtime.mjs +218 -0
- package/dist/plugins/index.d.mts +32 -0
- package/dist/plugins/index.mjs +163 -0
- package/dist/sandbox/index.d.mts +255 -0
- package/dist/sandbox/index.mjs +945 -0
- package/dist/storage/r2.d.mts +31 -0
- package/dist/storage/r2.mjs +116 -0
- package/dist/stream-DdbcvKi0.d.mts +78 -0
- package/package.json +109 -0
- package/src/auth/cloudflare-access.ts +303 -0
- package/src/auth/index.ts +16 -0
- package/src/cache/config.ts +81 -0
- package/src/cache/runtime.ts +328 -0
- package/src/cloudflare.d.ts +31 -0
- package/src/db/d1-introspector.ts +120 -0
- package/src/db/d1.ts +112 -0
- package/src/db/do-class.ts +275 -0
- package/src/db/do-dialect.ts +125 -0
- package/src/db/do-playground-routes.ts +65 -0
- package/src/db/do-preview-routes.ts +48 -0
- package/src/db/do-preview-sign.ts +100 -0
- package/src/db/do-preview.ts +268 -0
- package/src/db/do-types.ts +12 -0
- package/src/db/do.ts +62 -0
- package/src/db/playground-middleware.ts +340 -0
- package/src/db/playground-toolbar.ts +341 -0
- package/src/db/playground.ts +49 -0
- package/src/db/preview-toolbar.ts +220 -0
- package/src/index.ts +285 -0
- package/src/media/images-runtime.ts +353 -0
- package/src/media/images.ts +114 -0
- package/src/media/stream-runtime.ts +392 -0
- package/src/media/stream.ts +118 -0
- package/src/plugins/index.ts +7 -0
- package/src/plugins/vectorize-search.ts +393 -0
- package/src/sandbox/bridge.ts +1008 -0
- package/src/sandbox/index.ts +13 -0
- package/src/sandbox/runner.ts +357 -0
- package/src/sandbox/types.ts +181 -0
- package/src/sandbox/wrapper.ts +238 -0
- package/src/storage/r2.ts +200 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Images Media Provider
|
|
3
|
+
*
|
|
4
|
+
* Provides integration with Cloudflare Images for image hosting and transformation.
|
|
5
|
+
*
|
|
6
|
+
* Features:
|
|
7
|
+
* - Browse uploaded images
|
|
8
|
+
* - Upload new images
|
|
9
|
+
* - Delete images
|
|
10
|
+
* - URL-based image transformations (resize, format conversion, etc.)
|
|
11
|
+
*
|
|
12
|
+
* @see https://developers.cloudflare.com/images/
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { MediaProviderDescriptor } from "emdash/media";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Cloudflare Images configuration
|
|
19
|
+
*/
|
|
20
|
+
export interface CloudflareImagesConfig {
|
|
21
|
+
/**
|
|
22
|
+
* Cloudflare Account ID (for API calls)
|
|
23
|
+
* If not provided, reads from accountIdEnvVar at runtime
|
|
24
|
+
*/
|
|
25
|
+
accountId?: string;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Environment variable name containing the Account ID
|
|
29
|
+
* @default "CF_ACCOUNT_ID"
|
|
30
|
+
*/
|
|
31
|
+
accountIdEnvVar?: string;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Cloudflare Images Account Hash (for delivery URLs)
|
|
35
|
+
* This is different from the Account ID - find it in the Cloudflare dashboard
|
|
36
|
+
* under Images > Overview > "Account Hash"
|
|
37
|
+
* If not provided, reads from accountHashEnvVar at runtime
|
|
38
|
+
*/
|
|
39
|
+
accountHash?: string;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Environment variable name containing the Account Hash
|
|
43
|
+
* @default "CF_IMAGES_ACCOUNT_HASH"
|
|
44
|
+
*/
|
|
45
|
+
accountHashEnvVar?: string;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* API Token with Images permissions
|
|
49
|
+
* If not provided, reads from apiTokenEnvVar at runtime
|
|
50
|
+
* Should have "Cloudflare Images: Read" and "Cloudflare Images: Edit" permissions
|
|
51
|
+
*/
|
|
52
|
+
apiToken?: string;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Environment variable name containing the API token
|
|
56
|
+
* @default "CF_IMAGES_TOKEN"
|
|
57
|
+
*/
|
|
58
|
+
apiTokenEnvVar?: string;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Custom delivery domain (optional)
|
|
62
|
+
* If not specified, uses imagedelivery.net
|
|
63
|
+
* @example "images.example.com"
|
|
64
|
+
*/
|
|
65
|
+
deliveryDomain?: string;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Default variant to use for display
|
|
69
|
+
* @default "public"
|
|
70
|
+
*/
|
|
71
|
+
defaultVariant?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Cloudflare Images icon (inline SVG as data URL)
|
|
75
|
+
const IMAGES_ICON = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="none" viewBox="0 0 64 64"><path fill="#F63" d="M56 11.92H8l-2 2v39.87l2 2h48l2-2V13.92l-2-2Zm-2 4v18.69l-8-6.55-2.62.08-5.08 4.68-5.43-4-2.47.08-14 11.7-6.4-4.4V15.92h44ZM10 51.79V41.08l5.3 3.7 2.42-.11L31.75 33l5.5 4 2.54-.14 5-4.63L54 39.77v12l-44 .02Z"/><path fill="#F63" d="M19.08 32.16a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z"/></svg>')}`;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Cloudflare Images media provider
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* ```ts
|
|
82
|
+
* import { cloudflareImages } from "@emdash-cms/cloudflare";
|
|
83
|
+
*
|
|
84
|
+
* emdash({
|
|
85
|
+
* mediaProviders: [
|
|
86
|
+
* // Uses CF_ACCOUNT_ID and CF_IMAGES_TOKEN env vars by default
|
|
87
|
+
* cloudflareImages({}),
|
|
88
|
+
*
|
|
89
|
+
* // Or with custom env var names
|
|
90
|
+
* cloudflareImages({
|
|
91
|
+
* accountIdEnvVar: "MY_CF_ACCOUNT",
|
|
92
|
+
* apiTokenEnvVar: "MY_CF_IMAGES_KEY",
|
|
93
|
+
* }),
|
|
94
|
+
* ],
|
|
95
|
+
* })
|
|
96
|
+
* ```
|
|
97
|
+
*/
|
|
98
|
+
export function cloudflareImages(
|
|
99
|
+
config: CloudflareImagesConfig,
|
|
100
|
+
): MediaProviderDescriptor<CloudflareImagesConfig> {
|
|
101
|
+
return {
|
|
102
|
+
id: "cloudflare-images",
|
|
103
|
+
name: "Cloudflare Images",
|
|
104
|
+
icon: IMAGES_ICON,
|
|
105
|
+
entrypoint: "@emdash-cms/cloudflare/media/images-runtime",
|
|
106
|
+
capabilities: {
|
|
107
|
+
browse: true,
|
|
108
|
+
search: false, // Images API doesn't support search
|
|
109
|
+
upload: true,
|
|
110
|
+
delete: true,
|
|
111
|
+
},
|
|
112
|
+
config,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Stream 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 { CloudflareStreamConfig } from "./stream.js";
|
|
19
|
+
|
|
20
|
+
/** Safely extract a string from an unknown value */
|
|
21
|
+
function toString(value: unknown): string | undefined {
|
|
22
|
+
return typeof value === "string" ? value : undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Type guard: check if value is a record-like object */
|
|
26
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
27
|
+
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve a config value, checking env var if direct value not provided
|
|
32
|
+
*/
|
|
33
|
+
function resolveEnvValue(
|
|
34
|
+
directValue: string | undefined,
|
|
35
|
+
envVarName: string | undefined,
|
|
36
|
+
defaultEnvVar: string,
|
|
37
|
+
serviceName: string,
|
|
38
|
+
): string {
|
|
39
|
+
if (directValue) return directValue;
|
|
40
|
+
const envVar = envVarName || defaultEnvVar;
|
|
41
|
+
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Worker binding accessed from untyped env object
|
|
42
|
+
const value = (env as Record<string, string | undefined>)[envVar];
|
|
43
|
+
if (!value) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`${serviceName}: Missing ${envVar}. Set it as an environment variable or provide it directly in config.`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Runtime implementation for Cloudflare Stream provider
|
|
53
|
+
*/
|
|
54
|
+
export const createMediaProvider: CreateMediaProviderFn<CloudflareStreamConfig> = (config) => {
|
|
55
|
+
const { customerSubdomain, controls = true, autoplay = false, loop = false, muted } = config;
|
|
56
|
+
|
|
57
|
+
// Resolve credentials from config or env vars
|
|
58
|
+
const accountId = resolveEnvValue(
|
|
59
|
+
config.accountId,
|
|
60
|
+
config.accountIdEnvVar,
|
|
61
|
+
"CF_ACCOUNT_ID",
|
|
62
|
+
"Cloudflare Stream",
|
|
63
|
+
);
|
|
64
|
+
const apiToken = resolveEnvValue(
|
|
65
|
+
config.apiToken,
|
|
66
|
+
config.apiTokenEnvVar,
|
|
67
|
+
"CF_STREAM_TOKEN",
|
|
68
|
+
"Cloudflare Stream",
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const apiBase = `https://api.cloudflare.com/client/v4/accounts/${accountId}/stream`;
|
|
72
|
+
const headers = { Authorization: `Bearer ${apiToken}` };
|
|
73
|
+
|
|
74
|
+
// Muted defaults to true if autoplay is enabled (browser requirement)
|
|
75
|
+
const isMuted = muted ?? autoplay;
|
|
76
|
+
|
|
77
|
+
const provider: MediaProvider = {
|
|
78
|
+
async list(options: MediaListOptions) {
|
|
79
|
+
const params = new URLSearchParams();
|
|
80
|
+
|
|
81
|
+
// Stream uses "after" for cursor-based pagination
|
|
82
|
+
if (options.cursor) {
|
|
83
|
+
params.set("after", options.cursor);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Stream uses "asc" boolean, default is newest first
|
|
87
|
+
params.set("asc", "false");
|
|
88
|
+
|
|
89
|
+
// Search by name if query provided
|
|
90
|
+
if (options.query) {
|
|
91
|
+
params.set("search", options.query);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const url = `${apiBase}?${params}`;
|
|
95
|
+
const response = await fetch(url, { headers });
|
|
96
|
+
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
throw new Error(`Cloudflare Stream API error: ${response.status}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const data: CloudflareStreamListResponse = await response.json();
|
|
102
|
+
|
|
103
|
+
if (!data.success) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`Cloudflare Stream API error: ${data.errors?.[0]?.message || "Unknown error"}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Get the last video's UID for cursor-based pagination
|
|
110
|
+
const lastVideo = data.result.at(-1);
|
|
111
|
+
const nextCursor = lastVideo?.uid;
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
items: data.result.map((video) => ({
|
|
115
|
+
id: video.uid,
|
|
116
|
+
filename: toString(video.meta?.name) || video.uid,
|
|
117
|
+
mimeType: "video/mp4",
|
|
118
|
+
width: video.input?.width,
|
|
119
|
+
height: video.input?.height,
|
|
120
|
+
previewUrl: video.thumbnail,
|
|
121
|
+
meta: {
|
|
122
|
+
duration: video.duration,
|
|
123
|
+
playback: video.playback,
|
|
124
|
+
status: video.status,
|
|
125
|
+
created: video.created,
|
|
126
|
+
modified: video.modified,
|
|
127
|
+
size: video.size,
|
|
128
|
+
},
|
|
129
|
+
})),
|
|
130
|
+
nextCursor: data.result.length > 0 ? nextCursor : undefined,
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
async get(id: string) {
|
|
135
|
+
const url = `${apiBase}/${id}`;
|
|
136
|
+
const response = await fetch(url, { headers });
|
|
137
|
+
|
|
138
|
+
if (!response.ok) {
|
|
139
|
+
if (response.status === 404) return null;
|
|
140
|
+
throw new Error(`Cloudflare Stream API error: ${response.status}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const data: CloudflareStreamResponse = await response.json();
|
|
144
|
+
|
|
145
|
+
if (!data.success) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const video = data.result;
|
|
150
|
+
return {
|
|
151
|
+
id: video.uid,
|
|
152
|
+
filename: toString(video.meta?.name) || video.uid,
|
|
153
|
+
mimeType: "video/mp4",
|
|
154
|
+
width: video.input?.width,
|
|
155
|
+
height: video.input?.height,
|
|
156
|
+
previewUrl: video.thumbnail,
|
|
157
|
+
meta: {
|
|
158
|
+
duration: video.duration,
|
|
159
|
+
playback: video.playback,
|
|
160
|
+
status: video.status,
|
|
161
|
+
created: video.created,
|
|
162
|
+
modified: video.modified,
|
|
163
|
+
size: video.size,
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
async upload(input) {
|
|
169
|
+
// Stream supports tus protocol for resumable uploads
|
|
170
|
+
// For simplicity, we'll use direct creator upload which creates an upload URL
|
|
171
|
+
// For large files, this would need to be enhanced with tus
|
|
172
|
+
|
|
173
|
+
// First, create a direct upload URL
|
|
174
|
+
const createResponse = await fetch(`${apiBase}/direct_upload`, {
|
|
175
|
+
method: "POST",
|
|
176
|
+
headers: {
|
|
177
|
+
...headers,
|
|
178
|
+
"Content-Type": "application/json",
|
|
179
|
+
},
|
|
180
|
+
body: JSON.stringify({
|
|
181
|
+
maxDurationSeconds: 3600, // 1 hour max
|
|
182
|
+
meta: {
|
|
183
|
+
name: input.filename,
|
|
184
|
+
},
|
|
185
|
+
}),
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
if (!createResponse.ok) {
|
|
189
|
+
const error = await createResponse.text();
|
|
190
|
+
throw new Error(`Failed to create upload URL: ${error}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const createData: CloudflareStreamDirectUploadResponse = await createResponse.json();
|
|
194
|
+
|
|
195
|
+
if (!createData.success) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`Failed to create upload URL: ${createData.errors?.[0]?.message || "Unknown error"}`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Upload the file to the provided URL
|
|
202
|
+
const uploadUrl = createData.result.uploadURL;
|
|
203
|
+
const formData = new FormData();
|
|
204
|
+
formData.append("file", input.file, input.filename);
|
|
205
|
+
|
|
206
|
+
const uploadResponse = await fetch(uploadUrl, {
|
|
207
|
+
method: "POST",
|
|
208
|
+
body: formData,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
if (!uploadResponse.ok) {
|
|
212
|
+
const error = await uploadResponse.text();
|
|
213
|
+
throw new Error(`Upload failed: ${error}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// The upload response contains the video details
|
|
217
|
+
// Wait a moment for the video to be processed
|
|
218
|
+
const videoId = createData.result.uid;
|
|
219
|
+
|
|
220
|
+
// Poll for the video to be ready (simple implementation)
|
|
221
|
+
let video: CloudflareStreamVideo | null = null;
|
|
222
|
+
for (let i = 0; i < 10; i++) {
|
|
223
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
224
|
+
|
|
225
|
+
const checkResponse = await fetch(`${apiBase}/${videoId}`, { headers });
|
|
226
|
+
if (checkResponse.ok) {
|
|
227
|
+
const checkData: CloudflareStreamResponse = await checkResponse.json();
|
|
228
|
+
if (checkData.success && checkData.result.status?.state !== "queued") {
|
|
229
|
+
video = checkData.result;
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (!video) {
|
|
236
|
+
// Return with pending status - thumbnail might not be ready yet
|
|
237
|
+
return {
|
|
238
|
+
id: videoId,
|
|
239
|
+
filename: input.filename,
|
|
240
|
+
mimeType: "video/mp4",
|
|
241
|
+
previewUrl: undefined,
|
|
242
|
+
meta: {
|
|
243
|
+
status: { state: "processing" },
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
id: video.uid,
|
|
250
|
+
filename: toString(video.meta?.name) || input.filename,
|
|
251
|
+
mimeType: "video/mp4",
|
|
252
|
+
width: video.input?.width,
|
|
253
|
+
height: video.input?.height,
|
|
254
|
+
previewUrl: video.thumbnail,
|
|
255
|
+
meta: {
|
|
256
|
+
duration: video.duration,
|
|
257
|
+
playback: video.playback,
|
|
258
|
+
status: video.status,
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
async delete(id: string) {
|
|
264
|
+
const response = await fetch(`${apiBase}/${id}`, {
|
|
265
|
+
method: "DELETE",
|
|
266
|
+
headers,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
if (!response.ok && response.status !== 404) {
|
|
270
|
+
throw new Error(`Cloudflare Stream delete failed: ${response.status}`);
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
getEmbed(value: MediaValue, options?: EmbedOptions): EmbedResult {
|
|
275
|
+
const rawPlayback = value.meta?.playback;
|
|
276
|
+
const playback = isRecord(rawPlayback) ? rawPlayback : undefined;
|
|
277
|
+
|
|
278
|
+
const hlsSrc = toString(playback?.hls);
|
|
279
|
+
const dashSrc = toString(playback?.dash);
|
|
280
|
+
|
|
281
|
+
// Build the Stream player iframe URL or use HLS/DASH directly
|
|
282
|
+
// For video embeds, we can use the HLS stream URL
|
|
283
|
+
if (hlsSrc) {
|
|
284
|
+
return {
|
|
285
|
+
type: "video",
|
|
286
|
+
sources: [
|
|
287
|
+
{ src: hlsSrc, type: "application/x-mpegURL" },
|
|
288
|
+
...(dashSrc ? [{ src: dashSrc, type: "application/dash+xml" }] : []),
|
|
289
|
+
],
|
|
290
|
+
poster: toString(value.meta?.thumbnail),
|
|
291
|
+
width: options?.width ?? value.width,
|
|
292
|
+
height: options?.height ?? value.height,
|
|
293
|
+
controls,
|
|
294
|
+
autoplay,
|
|
295
|
+
loop,
|
|
296
|
+
muted: isMuted,
|
|
297
|
+
playsinline: true,
|
|
298
|
+
preload: "metadata",
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Fallback: use the Stream embed player URL
|
|
303
|
+
const baseUrl = customerSubdomain
|
|
304
|
+
? `https://${customerSubdomain}`
|
|
305
|
+
: `https://customer-${accountId.slice(0, 8)}.cloudflarestream.com`;
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
type: "video",
|
|
309
|
+
src: `${baseUrl}/${value.id}/manifest/video.m3u8`,
|
|
310
|
+
poster: `${baseUrl}/${value.id}/thumbnails/thumbnail.jpg`,
|
|
311
|
+
width: options?.width ?? value.width,
|
|
312
|
+
height: options?.height ?? value.height,
|
|
313
|
+
controls,
|
|
314
|
+
autoplay,
|
|
315
|
+
loop,
|
|
316
|
+
muted: isMuted,
|
|
317
|
+
playsinline: true,
|
|
318
|
+
preload: "metadata",
|
|
319
|
+
};
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
getThumbnailUrl(id: string, _mimeType?: string, options?: { width?: number; height?: number }) {
|
|
323
|
+
// For videos, return a thumbnail/poster image
|
|
324
|
+
const baseUrl = customerSubdomain
|
|
325
|
+
? `https://${customerSubdomain}`
|
|
326
|
+
: `https://customer-${accountId.slice(0, 8)}.cloudflarestream.com`;
|
|
327
|
+
|
|
328
|
+
// Stream supports thumbnail customization via URL params
|
|
329
|
+
const width = options?.width || 400;
|
|
330
|
+
const height = options?.height;
|
|
331
|
+
let url = `${baseUrl}/${id}/thumbnails/thumbnail.jpg?width=${width}`;
|
|
332
|
+
if (height) url += `&height=${height}`;
|
|
333
|
+
return url;
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
return provider;
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
// Cloudflare Stream API response types
|
|
341
|
+
interface CloudflareStreamListResponse {
|
|
342
|
+
success: boolean;
|
|
343
|
+
errors?: Array<{ message: string }>;
|
|
344
|
+
result: CloudflareStreamVideo[];
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
interface CloudflareStreamResponse {
|
|
348
|
+
success: boolean;
|
|
349
|
+
errors?: Array<{ message: string }>;
|
|
350
|
+
result: CloudflareStreamVideo;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
interface CloudflareStreamDirectUploadResponse {
|
|
354
|
+
success: boolean;
|
|
355
|
+
errors?: Array<{ message: string }>;
|
|
356
|
+
result: {
|
|
357
|
+
uploadURL: string;
|
|
358
|
+
uid: string;
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
interface CloudflareStreamVideo {
|
|
363
|
+
uid: string;
|
|
364
|
+
thumbnail: string;
|
|
365
|
+
thumbnailTimestampPct?: number;
|
|
366
|
+
readyToStream: boolean;
|
|
367
|
+
status: {
|
|
368
|
+
state: string;
|
|
369
|
+
pctComplete?: string;
|
|
370
|
+
errorReasonCode?: string;
|
|
371
|
+
errorReasonText?: string;
|
|
372
|
+
};
|
|
373
|
+
meta?: Record<string, unknown>;
|
|
374
|
+
created: string;
|
|
375
|
+
modified: string;
|
|
376
|
+
size: number;
|
|
377
|
+
preview?: string;
|
|
378
|
+
allowedOrigins?: string[];
|
|
379
|
+
requireSignedURLs: boolean;
|
|
380
|
+
uploaded?: string;
|
|
381
|
+
scheduledDeletion?: string;
|
|
382
|
+
input?: {
|
|
383
|
+
width: number;
|
|
384
|
+
height: number;
|
|
385
|
+
};
|
|
386
|
+
playback?: {
|
|
387
|
+
hls: string;
|
|
388
|
+
dash: string;
|
|
389
|
+
};
|
|
390
|
+
watermark?: unknown;
|
|
391
|
+
duration: number;
|
|
392
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Stream Media Provider
|
|
3
|
+
*
|
|
4
|
+
* Provides integration with Cloudflare Stream for video hosting and streaming.
|
|
5
|
+
*
|
|
6
|
+
* Features:
|
|
7
|
+
* - Browse uploaded videos
|
|
8
|
+
* - Upload new videos (direct upload)
|
|
9
|
+
* - Delete videos
|
|
10
|
+
* - HLS/DASH streaming URLs
|
|
11
|
+
* - Thumbnail generation
|
|
12
|
+
*
|
|
13
|
+
* @see https://developers.cloudflare.com/stream/
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { MediaProviderDescriptor } from "emdash/media";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Cloudflare Stream configuration
|
|
20
|
+
*/
|
|
21
|
+
export interface CloudflareStreamConfig {
|
|
22
|
+
/**
|
|
23
|
+
* Cloudflare Account ID
|
|
24
|
+
* If not provided, reads from accountIdEnvVar at runtime
|
|
25
|
+
*/
|
|
26
|
+
accountId?: string;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Environment variable name containing the Account ID
|
|
30
|
+
* @default "CF_ACCOUNT_ID"
|
|
31
|
+
*/
|
|
32
|
+
accountIdEnvVar?: string;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* API Token with Stream permissions
|
|
36
|
+
* If not provided, reads from apiTokenEnvVar at runtime
|
|
37
|
+
* Should have "Stream: Read" and "Stream: Edit" permissions
|
|
38
|
+
*/
|
|
39
|
+
apiToken?: string;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Environment variable name containing the API token
|
|
43
|
+
* @default "CF_STREAM_TOKEN"
|
|
44
|
+
*/
|
|
45
|
+
apiTokenEnvVar?: string;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Customer subdomain for Stream delivery (optional)
|
|
49
|
+
* If not provided, uses customer-{hash}.cloudflarestream.com format
|
|
50
|
+
*/
|
|
51
|
+
customerSubdomain?: string;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Default player controls setting
|
|
55
|
+
* @default true
|
|
56
|
+
*/
|
|
57
|
+
controls?: boolean;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Autoplay videos (muted by default to comply with browser policies)
|
|
61
|
+
* @default false
|
|
62
|
+
*/
|
|
63
|
+
autoplay?: boolean;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Loop videos
|
|
67
|
+
* @default false
|
|
68
|
+
*/
|
|
69
|
+
loop?: boolean;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Mute videos
|
|
73
|
+
* @default false (true if autoplay is enabled)
|
|
74
|
+
*/
|
|
75
|
+
muted?: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Cloudflare Stream icon (inline SVG as data URL)
|
|
79
|
+
const STREAM_ICON = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="none" viewBox="0 0 64 64"><g clip-path="url(#a)"><path fill="#F63" d="M59.87 30.176a11.73 11.73 0 0 0-8-2.72 19.3 19.3 0 0 0-37-4.59 13.63 13.63 0 0 0-9.67 3.19 14.599 14.599 0 0 0-5.2 11 14.24 14.24 0 0 0 14.18 14.25h37.88a12 12 0 0 0 7.81-21.13Zm-7.81 17.13H14.19A10.24 10.24 0 0 1 4 37.086a10.58 10.58 0 0 1 3.77-8 9.55 9.55 0 0 1 6.23-2.25c.637 0 1.273.058 1.9.17l1.74.31.51-1.69A15.29 15.29 0 0 1 48 29.686l.1 2.32 2.26-.36a8.239 8.239 0 0 1 6.91 1.62 8.098 8.098 0 0 1 2.73 6.1 8 8 0 0 1-7.94 7.94Z"/><path fill="#F63" fill-rule="evenodd" d="m25.72 24.89 3.02-1.72 15.085 8.936.004 3.44-15.087 8.973L25.72 42.8V24.89Zm4 3.51v10.883l9.168-5.452L29.72 28.4Z" clip-rule="evenodd"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h64v64H0z"/></clipPath></defs></svg>')}`;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Cloudflare Stream media provider
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* ```ts
|
|
86
|
+
* import { cloudflareStream } from "@emdash-cms/cloudflare";
|
|
87
|
+
*
|
|
88
|
+
* emdash({
|
|
89
|
+
* mediaProviders: [
|
|
90
|
+
* // Uses CF_ACCOUNT_ID and CF_STREAM_TOKEN env vars by default
|
|
91
|
+
* cloudflareStream({}),
|
|
92
|
+
*
|
|
93
|
+
* // Or with custom env var names
|
|
94
|
+
* cloudflareStream({
|
|
95
|
+
* accountIdEnvVar: "MY_CF_ACCOUNT",
|
|
96
|
+
* apiTokenEnvVar: "MY_CF_STREAM_KEY",
|
|
97
|
+
* }),
|
|
98
|
+
* ],
|
|
99
|
+
* })
|
|
100
|
+
* ```
|
|
101
|
+
*/
|
|
102
|
+
export function cloudflareStream(
|
|
103
|
+
config: CloudflareStreamConfig,
|
|
104
|
+
): MediaProviderDescriptor<CloudflareStreamConfig> {
|
|
105
|
+
return {
|
|
106
|
+
id: "cloudflare-stream",
|
|
107
|
+
name: "Cloudflare Stream",
|
|
108
|
+
icon: STREAM_ICON,
|
|
109
|
+
entrypoint: "@emdash-cms/cloudflare/media/stream-runtime",
|
|
110
|
+
capabilities: {
|
|
111
|
+
browse: true,
|
|
112
|
+
search: true,
|
|
113
|
+
upload: true,
|
|
114
|
+
delete: true,
|
|
115
|
+
},
|
|
116
|
+
config,
|
|
117
|
+
};
|
|
118
|
+
}
|