| 1 | import * as Effect from "effect/Effect"; |
| 2 | import type { Input, InputProps } from "../../Input.ts"; |
| 3 | import * as CoreNamespace from "../../Namespace.ts"; |
| 4 | import { isResource } from "../../Resource.ts"; |
| 5 | import { AccountApiToken } from "../ApiToken/AccountApiToken.ts"; |
| 6 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 7 | import type { Bucket } from "../R2/Bucket.ts"; |
| 8 | import { |
| 9 | SearchInstance, |
| 10 | type SearchInstanceProps, |
| 11 | type SourceParams, |
| 12 | } from "./SearchInstance.ts"; |
| 13 | import type { SearchNamespace } from "./SearchNamespace.ts"; |
| 14 | import { SearchToken } from "./SearchToken.ts"; |
| 15 | |
| 16 | type WebCrawlerParams = NonNullable<SourceParams["webCrawler"]>; |
| 17 | |
| 18 | /** |
| 19 | * How a web crawler discovers and parses pages — Cloudflare's `parseType` |
| 20 | * folded together with its parse options. Defaults to `type: "sitemap"`. |
| 21 | */ |
| 22 | export type Parse = { |
| 23 | /** |
| 24 | * How pages are discovered: |
| 25 | * - `"sitemap"` (default) — read `<seed>/sitemap.xml` (found via |
| 26 | * `robots.txt`) and index the URLs it lists. |
| 27 | * - `"discover"` — start at `source` and follow links. |
| 28 | * @default "sitemap" |
| 29 | */ |
| 30 | type?: NonNullable<WebCrawlerParams["parseType"]>; |
| 31 | } & NonNullable<WebCrawlerParams["parseOptions"]>; |
| 32 | |
| 33 | /** |
| 34 | * Where crawled content is stored. Cloudflare provisions managed storage by |
| 35 | * default; set this to store output in an R2 bucket you control. |
| 36 | */ |
| 37 | export type Store = { |
| 38 | /** R2 bucket to store crawl output in. */ |
| 39 | bucket: Bucket; |
| 40 | /** R2 data-residency jurisdiction for the store bucket. */ |
| 41 | jurisdiction?: string; |
| 42 | }; |
| 43 | |
| 44 | /** |
| 45 | * Props common to every AI Search pipeline, regardless of data source. The |
| 46 | * underlying instance's `type`, `source`, and `sourceParams` are derived from |
| 47 | * the source-specific variant, so they're omitted here. |
| 48 | */ |
| 49 | export type SharedProps = Omit< |
| 50 | InputProps<SearchInstanceProps, "type">, |
| 51 | "type" | "source" | "sourceParams" | "namespace" |
| 52 | > & { |
| 53 | /** |
| 54 | * Namespace to group this pipeline under. Pass an {@link SearchNamespace} |
| 55 | * resource — the engine orders this pipeline after the namespace on deploy |
| 56 | * and tears it down before the namespace on destroy. Omit to place the |
| 57 | * pipeline in the account-provided `default` namespace. The namespace is |
| 58 | * immutable — changing it triggers a replacement. |
| 59 | * @default the account-provided `default` namespace |
| 60 | */ |
| 61 | namespace?: SearchNamespace; |
| 62 | }; |
| 63 | |
| 64 | /** |
| 65 | * An R2-backed AI Search pipeline. Passing an {@link Bucket} as `source` |
| 66 | * selects R2 as the data source. |
| 67 | */ |
| 68 | export type R2Props = SharedProps & { |
| 69 | /** |
| 70 | * The R2 bucket to index. AI Search needs a service token to read it; the |
| 71 | * construct provisions one unless you pass your own `tokenId`. |
| 72 | */ |
| 73 | source: Bucket; |
| 74 | /** Only index object keys under this prefix. */ |
| 75 | prefix?: string; |
| 76 | /** |
| 77 | * Micromatch glob patterns; only objects matching at least one are |
| 78 | * indexed (`*` within a path segment, `**` across segments). Max 10. |
| 79 | */ |
| 80 | include?: string[]; |
| 81 | /** |
| 82 | * Micromatch glob patterns; objects matching any are skipped. Exclude |
| 83 | * takes precedence over `include`. Max 10. |
| 84 | */ |
| 85 | exclude?: string[]; |
| 86 | /** R2 data-residency jurisdiction of the source bucket. */ |
| 87 | jurisdiction?: string; |
| 88 | parse?: never; |
| 89 | store?: never; |
| 90 | }; |
| 91 | |
| 92 | /** |
| 93 | * A web-crawler-backed AI Search pipeline. Passing a URL as `source` selects |
| 94 | * the web crawler as the data source (no service token needed). |
| 95 | */ |
| 96 | export type WebCrawlerProps = SharedProps & { |
| 97 | /** Seed URL to crawl and index. */ |
| 98 | source: Input<string>; |
| 99 | /** How pages are discovered and parsed. */ |
| 100 | parse?: Parse; |
| 101 | /** Where crawl output is stored (defaults to managed storage). */ |
| 102 | store?: Store; |
| 103 | prefix?: never; |
| 104 | include?: never; |
| 105 | exclude?: never; |
| 106 | jurisdiction?: never; |
| 107 | }; |
| 108 | |
| 109 | /** |
| 110 | * Props for the {@link Search} construct — a union discriminated by what you |
| 111 | * pass as `source`: an {@link Bucket} for an R2 source, or a URL string for |
| 112 | * a web crawl. |
| 113 | */ |
| 114 | export type Props = R2Props | WebCrawlerProps; |
| 115 | |
| 116 | /** |
| 117 | * The result of the {@link Search} construct. It *is* the underlying |
| 118 | * {@link SearchInstance}, augmented with the managed `serviceToken`, so it |
| 119 | * can be passed anywhere a `SearchInstance` is expected — |
| 120 | * `Cloudflare.AI.QuerySearch(search)`, a Worker's `env`, etc. |
| 121 | */ |
| 122 | export type Search = SearchInstance & { |
| 123 | /** |
| 124 | * The managed AI Search service token minted for an R2 source, or |
| 125 | * `undefined` when the source is a web crawler (no token needed) or you |
| 126 | * supplied your own `tokenId`. |
| 127 | */ |
| 128 | serviceToken: SearchToken | undefined; |
| 129 | }; |
| 130 | |
| 131 | /** |
| 132 | * A convenience construct over {@link SearchInstance} that auto-creates the |
| 133 | * sub-resources an AI Search instance typically needs, so a single call wires |
| 134 | * up a working pipeline. The data source is chosen by what you pass as |
| 135 | * `source` — an {@link Bucket} for R2, or a URL for a web crawl: |
| 136 | * |
| 137 | * - For an R2 source, it mints a least-privilege {@link AccountApiToken} |
| 138 | * (`AI Search Index Engine`, stable child `ApiToken`) and an |
| 139 | * {@link SearchToken} wrapping it (stable child `Token`), then passes |
| 140 | * that token to the instance. |
| 141 | * Cloudflare requires a service token to read an R2 bucket and only |
| 142 | * provisions one through the dashboard / Wrangler — never on a |
| 143 | * programmatic API create — so the construct provisions it for you. Pass |
| 144 | * your own `tokenId` to skip minting and reuse an existing token. |
| 145 | * - It creates the {@link SearchInstance} (child `SearchInstance`) with the |
| 146 | * remaining props. |
| 147 | * |
| 148 | * Drop down to the low-level resources directly when you need to share a |
| 149 | * token across instances, adopt an existing one, or bind a namespace. |
| 150 | * |
| 151 | * The returned value *is* an {@link SearchInstance} (augmented with the |
| 152 | * managed `serviceToken`, `undefined` for a web crawler), so a `Search` |
| 153 | * is usable anywhere a `SearchInstance` is expected — pass it straight to |
| 154 | * `Cloudflare.AI.QuerySearch(search)` or a Worker's `env`. |
| 155 | * |
| 156 | * ### Creating an AI Search pipeline |
| 157 | * **Example:** R2-backed instance (token provisioned for you) |
| 158 | * Pass an {@link Bucket} as `source` — its presence selects R2. |
| 159 | * ```typescript |
| 160 | * const bucket = yield* Cloudflare.R2.Bucket("docs"); |
| 161 | * const search = yield* Cloudflare.AI.Search("docs-search", { |
| 162 | * source: bucket, |
| 163 | * }); |
| 164 | * ``` |
| 165 | * |
| 166 | * **Example:** Index only part of a bucket |
| 167 | * ```typescript |
| 168 | * const search = yield* Cloudflare.AI.Search("docs-search", { |
| 169 | * source: bucket, |
| 170 | * prefix: "docs/", |
| 171 | * include: ["/docs/**"], |
| 172 | * exclude: ["/docs/drafts/**"], |
| 173 | * }); |
| 174 | * ``` |
| 175 | * |
| 176 | * **Example:** Reuse an existing service token |
| 177 | * ```typescript |
| 178 | * const search = yield* Cloudflare.AI.Search("docs-search", { |
| 179 | * source: bucket, |
| 180 | * tokenId: existingToken.id, |
| 181 | * }); |
| 182 | * ``` |
| 183 | * |
| 184 | * **Example:** Web-crawler source |
| 185 | * Pass a URL as `source` to crawl and index a website (no service token |
| 186 | * needed). `parse.type` defaults to `"sitemap"`; use `"discover"` to follow |
| 187 | * links from the seed instead. |
| 188 | * ```typescript |
| 189 | * const search = yield* Cloudflare.AI.Search("site-search", { |
| 190 | * source: "https://example.com", |
| 191 | * parse: { type: "discover", contentSelector: [{ path: "/docs", selector: "main" }] }, |
| 192 | * }); |
| 193 | * ``` |
| 194 | * |
| 195 | * **Example:** Store crawl output in your own bucket |
| 196 | * ```typescript |
| 197 | * const store = yield* Cloudflare.R2.Bucket("crawl-store"); |
| 198 | * const search = yield* Cloudflare.AI.Search("site-search", { |
| 199 | * source: "https://example.com", |
| 200 | * parse: { type: "discover" }, |
| 201 | * store: { bucket: store }, |
| 202 | * }); |
| 203 | * ``` |
| 204 | * |
| 205 | * ### Binding to an Effect Worker |
| 206 | * |
| 207 | * The returned `search` is an {@link SearchInstance}. Bind it during the |
| 208 | * Worker's init phase with `Cloudflare.AI.QuerySearch(search)`, which |
| 209 | * attaches the single-instance `ai_search` binding and hands back an |
| 210 | * Effect-native client whose `search` / `chatCompletions` methods return |
| 211 | * `Effect`s. Provide `Cloudflare.AI.QuerySearchBinding` in the Worker's |
| 212 | * runtime layer. |
| 213 | * |
| 214 | * **Example:** Effect Worker that answers from AI Search |
| 215 | * ```typescript |
| 216 | * import * as Cloudflare from "alchemy/Cloudflare"; |
| 217 | * import * as Effect from "effect/Effect"; |
| 218 | * import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 219 | * import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 220 | * |
| 221 | * export default class Api extends Cloudflare.Worker<Api>()( |
| 222 | * "api", |
| 223 | * { main: import.meta.url }, |
| 224 | * Effect.gen(function* () { |
| 225 | * const bucket = yield* Cloudflare.R2.Bucket("docs"); |
| 226 | * const aiSearch = yield* Cloudflare.AI.Search("docs-search", { |
| 227 | * source: bucket, |
| 228 | * }); |
| 229 | * const search = yield* Cloudflare.AI.QuerySearch(aiSearch); |
| 230 | * |
| 231 | * return { |
| 232 | * fetch: Effect.gen(function* () { |
| 233 | * const request = yield* HttpServerRequest; |
| 234 | * const query = new URL(request.url).searchParams.get("q") ?? ""; |
| 235 | * const answer = yield* search.chatCompletions({ |
| 236 | * messages: [{ role: "user", content: query }], |
| 237 | * }); |
| 238 | * return yield* HttpServerResponse.json(answer); |
| 239 | * }), |
| 240 | * }; |
| 241 | * }).pipe(Effect.provide(Cloudflare.AI.QuerySearchBinding)), |
| 242 | * ) {} |
| 243 | * ``` |
| 244 | * |
| 245 | * ### Binding to an Async Worker |
| 246 | * |
| 247 | * For a vanilla `async fetch` Worker, pass the `search` under `Worker.env`. |
| 248 | * The engine attaches the same single-instance `ai_search` binding (see |
| 249 | * `toBinding` in `WorkerAsyncBindings.ts`), orders the deploy |
| 250 | * bucket → instance → worker, and `InferEnv` types `env.SEARCH` as the |
| 251 | * runtime `SearchInstance` handle — no hand-written types. |
| 252 | * |
| 253 | * **Example:** Async Worker that answers from AI Search |
| 254 | * ```typescript |
| 255 | * // stack.ts |
| 256 | * const bucket = yield* Cloudflare.R2.Bucket("docs"); |
| 257 | * const search = yield* Cloudflare.AI.Search("docs-search", { |
| 258 | * source: bucket, |
| 259 | * }); |
| 260 | * |
| 261 | * export const Api = Cloudflare.Worker("api", { |
| 262 | * main: "./worker.ts", |
| 263 | * env: { SEARCH: search }, |
| 264 | * }); |
| 265 | * export type ApiEnv = Cloudflare.InferEnv<typeof Api>; |
| 266 | * |
| 267 | * // worker.ts |
| 268 | * import type { ApiEnv } from "./stack.ts"; |
| 269 | * export default { |
| 270 | * async fetch(request: Request, env: ApiEnv): Promise<Response> { |
| 271 | * const query = new URL(request.url).searchParams.get("q") ?? ""; |
| 272 | * const answer = await env.SEARCH.chatCompletions({ |
| 273 | * messages: [{ role: "user", content: query }], |
| 274 | * }); |
| 275 | * return Response.json(answer); |
| 276 | * }, |
| 277 | * }; |
| 278 | * ``` |
| 279 | * |
| 280 | * @see https://developers.cloudflare.com/ai-search/ |
| 281 | * |
| 282 | * @resource |
| 283 | * @product AI Search |
| 284 | * @category AI |
| 285 | */ |
| 286 | export const Search = (id: string, props: Props) => |
| 287 | Effect.gen(function* () { |
| 288 | const { |
| 289 | source, |
| 290 | prefix, |
| 291 | include, |
| 292 | exclude, |
| 293 | jurisdiction, |
| 294 | parse, |
| 295 | store, |
| 296 | namespace, |
| 297 | ...shared |
| 298 | } = props; |
| 299 | |
| 300 | let tokenId = shared.tokenId; |
| 301 | let serviceToken: SearchToken | undefined; |
| 302 | let type: "r2" | "web-crawler"; |
| 303 | let instanceSource: Input<string>; |
| 304 | let sourceParams: Input<SourceParams> | undefined; |
| 305 | |
| 306 | // Discriminate the data source on what `source` is: an Bucket (a resource) |
| 307 | // indexes a bucket and needs a service token to read it; a URL crawls a seed |
| 308 | // and doesn't. |
| 309 | if (isResource(source)) { |
| 310 | const bucket = source as Bucket; |
| 311 | type = "r2"; |
| 312 | instanceSource = bucket.bucketName; |
| 313 | sourceParams = clean({ |
| 314 | prefix, |
| 315 | includeItems: include, |
| 316 | excludeItems: exclude, |
| 317 | r2Jurisdiction: jurisdiction, |
| 318 | }) as Input<SourceParams> | undefined; |
| 319 | |
| 320 | // Cloudflare requires a service token to read an R2 source and only |
| 321 | // auto-creates one via the dashboard/Wrangler — not on a programmatic |
| 322 | // API create. Mint one ourselves unless the caller passed a `tokenId`. |
| 323 | if (tokenId === undefined) { |
| 324 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 325 | const apiToken = yield* AccountApiToken("ApiToken", { |
| 326 | policies: [ |
| 327 | { |
| 328 | effect: "allow", |
| 329 | permissionGroups: ["AI Search Index Engine"], |
| 330 | resources: { |
| 331 | [`com.cloudflare.api.account.${accountId}`]: "*", |
| 332 | }, |
| 333 | }, |
| 334 | ], |
| 335 | }); |
| 336 | serviceToken = yield* SearchToken("Token", { |
| 337 | cfApiId: apiToken.tokenId, |
| 338 | cfApiKey: apiToken.value, |
| 339 | }); |
| 340 | tokenId = serviceToken.id; |
| 341 | } |
| 342 | } else { |
| 343 | type = "web-crawler"; |
| 344 | instanceSource = source as Input<string>; |
| 345 | |
| 346 | const { type: parseType, ...parseOptions } = parse ?? {}; |
| 347 | const webCrawler = clean({ |
| 348 | parseType, |
| 349 | parseOptions: clean(parseOptions), |
| 350 | storeOptions: store |
| 351 | ? clean({ |
| 352 | storageId: store.bucket.bucketName, |
| 353 | storageType: "r2" as const, |
| 354 | r2Jurisdiction: store.jurisdiction, |
| 355 | }) |
| 356 | : undefined, |
| 357 | }); |
| 358 | sourceParams = webCrawler |
| 359 | ? ({ webCrawler } as Input<SourceParams>) |
| 360 | : undefined; |
| 361 | } |
| 362 | |
| 363 | const instance = yield* SearchInstance("Instance", { |
| 364 | ...shared, |
| 365 | // The instance is keyed by namespace name; pass the namespace's `name` |
| 366 | // output so the engine orders instance-after-namespace. |
| 367 | namespace: namespace?.name, |
| 368 | type, |
| 369 | source: instanceSource, |
| 370 | tokenId, |
| 371 | sourceParams, |
| 372 | }); |
| 373 | |
| 374 | // Return the instance itself (augmented with the managed `serviceToken`) |
| 375 | // so a `Search` is usable anywhere a `SearchInstance` is expected — |
| 376 | // `Cloudflare.AI.QuerySearch(search)`, `env: { SEARCH: search }`, etc. |
| 377 | return Object.assign(instance, { serviceToken }) as Search; |
| 378 | }).pipe(CoreNamespace.push(id)); |
| 379 | |
| 380 | /** Drop `undefined` entries; return `undefined` when nothing is left. */ |
| 381 | const clean = <T extends object>( |
| 382 | obj: T, |
| 383 | ): { [K in keyof T]: T[K] } | undefined => { |
| 384 | const entries = Object.entries(obj).filter(([, v]) => v !== undefined); |
| 385 | return entries.length |
| 386 | ? (Object.fromEntries(entries) as { [K in keyof T]: T[K] }) |
| 387 | : undefined; |
| 388 | }; |