@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
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Vectorize Search Plugin
3
+ *
4
+ * Semantic search using Cloudflare Vectorize and Workers AI.
5
+ * This plugin provides a semantic search endpoint that complements
6
+ * the core FTS5-based search.
7
+ *
8
+ * Usage:
9
+ * 1. Add the plugin to your EmDash config
10
+ * 2. Configure Vectorize index and AI bindings in wrangler.toml
11
+ * 3. Access semantic search via plugin route
12
+ *
13
+ * @example
14
+ * ```typescript
15
+ * // astro.config.mjs
16
+ * import emdash from "emdash/astro";
17
+ * import { vectorizeSearch } from "@emdash-cms/cloudflare/plugins";
18
+ *
19
+ * export default defineConfig({
20
+ * integrations: [
21
+ * emdash({
22
+ * plugins: [
23
+ * vectorizeSearch({
24
+ * indexName: "emdash-content",
25
+ * model: "@cf/bge-base-en-v1.5",
26
+ * }),
27
+ * ],
28
+ * }),
29
+ * ],
30
+ * });
31
+ * ```
32
+ *
33
+ * @example
34
+ * ```toml
35
+ * # wrangler.toml
36
+ * [[vectorize]]
37
+ * binding = "VECTORIZE"
38
+ * index_name = "emdash-content"
39
+ *
40
+ * [ai]
41
+ * binding = "AI"
42
+ * ```
43
+ */
44
+
45
+ import type { PluginDefinition, PluginContext, RouteContext, ContentHookEvent } from "emdash";
46
+ import { extractPlainText } from "emdash";
47
+
48
+ /** Safely extract a string from an unknown value */
49
+ function toString(value: unknown): string {
50
+ return typeof value === "string" ? value : "";
51
+ }
52
+
53
+ /** Type guard: check if value is a record-like object */
54
+ function isRecord(value: unknown): value is Record<string, unknown> {
55
+ return value != null && typeof value === "object" && !Array.isArray(value);
56
+ }
57
+
58
+ /**
59
+ * Vectorize Search Plugin Configuration
60
+ */
61
+ export interface VectorizeSearchConfig {
62
+ /**
63
+ * Name of the Vectorize index
64
+ * @default "emdash-content"
65
+ */
66
+ indexName?: string;
67
+
68
+ /**
69
+ * Workers AI embedding model to use
70
+ * @default "@cf/bge-base-en-v1.5"
71
+ */
72
+ model?: string;
73
+
74
+ /**
75
+ * Collections to index. If not specified, indexes all collections
76
+ * that have search enabled in their config.
77
+ */
78
+ collections?: string[];
79
+ }
80
+
81
+ /**
82
+ * Get Cloudflare runtime environment from request
83
+ */
84
+ function getCloudflareEnv(request: Request): CloudflareEnv | null {
85
+ // Access runtime.env from Astro's Cloudflare adapter
86
+ // This is available when running on Cloudflare Workers
87
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, typescript-eslint(no-unsafe-type-assertion) -- Astro locals accessed via internal symbol; no typed API available
88
+ const locals = (request as any)[Symbol.for("astro.locals")];
89
+ if (locals?.runtime?.env) {
90
+ return locals.runtime.env;
91
+ }
92
+ return null;
93
+ }
94
+
95
+ /**
96
+ * Extract searchable text from content entry
97
+ */
98
+ function extractSearchableText(content: Record<string, unknown>): string {
99
+ const parts: string[] = [];
100
+
101
+ // Extract title if present
102
+ if (typeof content.title === "string") {
103
+ parts.push(content.title);
104
+ }
105
+
106
+ // Extract any string or Portable Text fields
107
+ for (const [key, value] of Object.entries(content)) {
108
+ if (key === "title" || key === "id" || key === "slug") continue;
109
+
110
+ if (typeof value === "string") {
111
+ // Could be plain text or JSON Portable Text
112
+ const text = extractPlainText(value);
113
+ if (text) parts.push(text);
114
+ } else if (Array.isArray(value)) {
115
+ // Assume Portable Text array
116
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, typescript-eslint(no-unsafe-type-assertion) -- Portable Text arrays are untyped at this point; extractPlainText handles validation
117
+ const text = extractPlainText(value as any);
118
+ if (text) parts.push(text);
119
+ }
120
+ }
121
+
122
+ return parts.join("\n");
123
+ }
124
+
125
+ /**
126
+ * Create a Vectorize Search plugin definition
127
+ *
128
+ * Note: This returns a plain plugin definition object, not a resolved plugin.
129
+ * It should be passed to the emdash() integration's plugins array.
130
+ */
131
+ export function vectorizeSearch(config: VectorizeSearchConfig = {}): PluginDefinition {
132
+ const model = config.model ?? "@cf/bge-base-en-v1.5";
133
+ const targetCollections = config.collections;
134
+
135
+ // Store env reference from routes for use in hooks
136
+ // (hooks don't have request context directly)
137
+ let cachedEnv: CloudflareEnv | null = null;
138
+
139
+ return {
140
+ id: "vectorize-search",
141
+ version: "1.0.0",
142
+ capabilities: ["read:content"],
143
+
144
+ hooks: {
145
+ /**
146
+ * Index content on save
147
+ *
148
+ * Note: Hooks don't have access to the request directly.
149
+ * We rely on the route handler being called first to cache the env,
150
+ * or the env being available through other means on Cloudflare.
151
+ */
152
+ "content:afterSave": {
153
+ handler: async (event: ContentHookEvent, _ctx: PluginContext): Promise<void> => {
154
+ const { content, collection } = event;
155
+
156
+ // Check if this collection should be indexed
157
+ if (targetCollections && !targetCollections.includes(collection)) {
158
+ return;
159
+ }
160
+
161
+ // On Cloudflare Workers, we need to get env from the execution context
162
+ // This is a limitation - hooks don't have request context
163
+ // The workaround is to use the query route first to cache the env
164
+ if (!cachedEnv) {
165
+ console.warn(
166
+ "[vectorize-search] Environment not available in hook. " +
167
+ "Call the /query route first to initialize, or reindex manually.",
168
+ );
169
+ return;
170
+ }
171
+
172
+ const env = cachedEnv;
173
+ if (!env.AI || !env.VECTORIZE) {
174
+ console.warn(
175
+ "[vectorize-search] AI or VECTORIZE binding not available, skipping indexing",
176
+ );
177
+ return;
178
+ }
179
+
180
+ try {
181
+ const text = extractSearchableText(content);
182
+ if (!text.trim()) {
183
+ return;
184
+ }
185
+
186
+ // Generate embedding
187
+ const embedResult = await env.AI.run(model, {
188
+ text: [text],
189
+ });
190
+
191
+ if (!embedResult?.data?.[0]) {
192
+ console.error("[vectorize-search] Failed to generate embedding");
193
+ return;
194
+ }
195
+
196
+ // Upsert to Vectorize
197
+ const contentId = toString(content.id);
198
+ const contentSlug = toString(content.slug);
199
+ const contentTitle = toString(content.title);
200
+
201
+ await env.VECTORIZE.upsert([
202
+ {
203
+ id: contentId,
204
+ values: embedResult.data[0],
205
+ metadata: {
206
+ collection,
207
+ slug: contentSlug ?? "",
208
+ title: contentTitle ?? "",
209
+ },
210
+ },
211
+ ]);
212
+
213
+ console.log(`[vectorize-search] Indexed ${collection}/${contentId}`);
214
+ } catch (error) {
215
+ console.error("[vectorize-search] Error indexing content:", error);
216
+ }
217
+ },
218
+ },
219
+
220
+ /**
221
+ * Remove from index on delete
222
+ */
223
+ "content:afterDelete": {
224
+ handler: async (
225
+ event: { id: string; collection: string },
226
+ _ctx: PluginContext,
227
+ ): Promise<void> => {
228
+ const { id, collection } = event;
229
+
230
+ // Check if this collection should be indexed
231
+ if (targetCollections && !targetCollections.includes(collection)) {
232
+ return;
233
+ }
234
+
235
+ if (!cachedEnv?.VECTORIZE) {
236
+ return;
237
+ }
238
+
239
+ try {
240
+ await cachedEnv.VECTORIZE.deleteByIds([id]);
241
+ console.log(`[vectorize-search] Removed ${collection}/${id} from index`);
242
+ } catch (error) {
243
+ console.error("[vectorize-search] Error removing from index:", error);
244
+ }
245
+ },
246
+ },
247
+ },
248
+
249
+ routes: {
250
+ /**
251
+ * Semantic search query
252
+ *
253
+ * GET /_emdash/api/plugins/vectorize-search/query?q=hello&limit=10
254
+ */
255
+ query: {
256
+ handler: async (ctx: RouteContext): Promise<unknown> => {
257
+ const { request } = ctx;
258
+ const input = isRecord(ctx.input) ? ctx.input : undefined;
259
+
260
+ // Cache env for hooks
261
+ const env = getCloudflareEnv(request);
262
+ if (env) {
263
+ cachedEnv = env;
264
+ }
265
+
266
+ if (!env?.AI || !env?.VECTORIZE) {
267
+ return {
268
+ error: "Vectorize or AI binding not available",
269
+ results: [],
270
+ };
271
+ }
272
+
273
+ const query = typeof input?.q === "string" ? input.q : undefined;
274
+ if (!query) {
275
+ return {
276
+ error: "Query parameter 'q' is required",
277
+ results: [],
278
+ };
279
+ }
280
+
281
+ try {
282
+ // Generate embedding for query
283
+ const embedResult = await env.AI.run(model, {
284
+ text: [query],
285
+ });
286
+
287
+ if (!embedResult?.data?.[0]) {
288
+ return {
289
+ error: "Failed to generate query embedding",
290
+ results: [],
291
+ };
292
+ }
293
+
294
+ // Query Vectorize
295
+ const limit = typeof input?.limit === "number" ? input.limit : 20;
296
+ const queryOptions: VectorizeQueryOptions = {
297
+ topK: limit,
298
+ returnMetadata: "all",
299
+ };
300
+
301
+ // Add collection filter if specified
302
+ const collection = typeof input?.collection === "string" ? input.collection : undefined;
303
+ if (collection) {
304
+ queryOptions.filter = {
305
+ collection,
306
+ };
307
+ }
308
+
309
+ const results = await env.VECTORIZE.query(embedResult.data[0], queryOptions);
310
+
311
+ return {
312
+ results: results.matches.map((match) => ({
313
+ id: match.id,
314
+ score: match.score,
315
+ collection: toString(match.metadata?.collection),
316
+ slug: toString(match.metadata?.slug),
317
+ title: toString(match.metadata?.title),
318
+ })),
319
+ };
320
+ } catch (error) {
321
+ console.error("[vectorize-search] Query error:", error);
322
+ return {
323
+ error: error instanceof Error ? error.message : "Query failed",
324
+ results: [],
325
+ };
326
+ }
327
+ },
328
+ },
329
+
330
+ /**
331
+ * Reindex all content
332
+ *
333
+ * POST /_emdash/api/plugins/vectorize-search/reindex
334
+ */
335
+ reindex: {
336
+ handler: async (ctx: RouteContext): Promise<unknown> => {
337
+ const { request } = ctx;
338
+
339
+ // Cache env
340
+ const env = getCloudflareEnv(request);
341
+ if (env) {
342
+ cachedEnv = env;
343
+ }
344
+
345
+ return { success: false, error: "REINDEX_NOT_SUPPORTED" };
346
+ },
347
+ },
348
+ },
349
+
350
+ admin: {
351
+ pages: [
352
+ {
353
+ path: "/settings",
354
+ label: "Vectorize Search",
355
+ icon: "search",
356
+ },
357
+ ],
358
+ },
359
+ };
360
+ }
361
+
362
+ // =============================================================================
363
+ // Cloudflare Types (minimal, for the plugin)
364
+ // =============================================================================
365
+
366
+ interface CloudflareEnv {
367
+ AI?: {
368
+ run(model: string, input: { text: string[] }): Promise<{ data: number[][] }>;
369
+ };
370
+ VECTORIZE?: VectorizeIndex;
371
+ }
372
+
373
+ interface VectorizeIndex {
374
+ upsert(
375
+ vectors: Array<{ id: string; values: number[]; metadata?: Record<string, unknown> }>,
376
+ ): Promise<void>;
377
+ deleteByIds(ids: string[]): Promise<void>;
378
+ query(vector: number[], options: VectorizeQueryOptions): Promise<{ matches: VectorizeMatch[] }>;
379
+ }
380
+
381
+ interface VectorizeQueryOptions {
382
+ topK: number;
383
+ returnMetadata?: "all" | "indexed" | "none";
384
+ filter?: Record<string, unknown>;
385
+ }
386
+
387
+ interface VectorizeMatch {
388
+ id: string;
389
+ score: number;
390
+ metadata?: Record<string, unknown>;
391
+ }
392
+
393
+ export default vectorizeSearch;