@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,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;
|