1import * as Effect from "effect/Effect";
2import type { Input, InputProps } from "../../Input.ts";
3import * as CoreNamespace from "../../Namespace.ts";
4import { isResource } from "../../Resource.ts";
5import { AccountApiToken } from "../ApiToken/AccountApiToken.ts";
6import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
7import type { Bucket } from "../R2/Bucket.ts";
8import {
9 SearchInstance,
10 type SearchInstanceProps,
11 type SourceParams,
12} from "./SearchInstance.ts";
13import type { SearchNamespace } from "./SearchNamespace.ts";
14import { SearchToken } from "./SearchToken.ts";
15
16type 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 */
22export 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 */
37export 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 */
49export 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 */
68export 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 */
96export 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 */
114export 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 */
122export 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 */
286export 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. */
381const 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};