diff --git a/examples/cloudflare-ai-search-namespace/.gitignore b/examples/cloudflare-ai-search-namespace/.gitignore new file mode 100644 index 0000000000..cc54d25a17 --- /dev/null +++ b/examples/cloudflare-ai-search-namespace/.gitignore @@ -0,0 +1,3 @@ + +# sst +.sst diff --git a/examples/cloudflare-ai-search-namespace/index.ts b/examples/cloudflare-ai-search-namespace/index.ts new file mode 100644 index 0000000000..a6113f0a17 --- /dev/null +++ b/examples/cloudflare-ai-search-namespace/index.ts @@ -0,0 +1,87 @@ +import { Resource } from "sst"; + +export default { + async fetch(req: Request) { + const url = new URL(req.url); + const path = url.pathname; + + // List all instances in the namespace. + if (req.method === "GET" && path === "/instances") { + const result = await Resource.Search.list(); + return Response.json(result); + } + + // Create an instance: POST /instances { "id": "my-docs" } + if (req.method === "POST" && path === "/instances") { + const body = (await req.json()) as { id: string }; + if (!body.id) + return Response.json({ error: "Missing 'id'" }, { status: 400 }); + const instance = await Resource.Search.create({ id: body.id }); + const info = await instance.info(); + return Response.json(info, { status: 201 }); + } + + // Delete an instance: DELETE /instances/my-docs + const deleteMatch = path.match(/^\/instances\/([^/]+)$/); + if (req.method === "DELETE" && deleteMatch) { + await Resource.Search.delete(deleteMatch[1]); + return new Response(null, { status: 204 }); + } + + // Upload a document to a specific instance: + // PUT /instances/my-docs/items?filename=guide.md + const uploadMatch = path.match(/^\/instances\/([^/]+)\/items$/); + if (req.method === "PUT" && uploadMatch) { + const instance = Resource.Search.get(uploadMatch[1]); + const filename = url.searchParams.get("filename"); + if (!filename || !req.body) + return Response.json( + { error: "Provide ?filename= and a request body" }, + { status: 400 }, + ); + const item = await instance.items.uploadAndPoll(filename, req.body); + return Response.json(item, { status: 201 }); + } + + // Search a specific instance: + // GET /instances/my-docs/search?q=caching + const searchMatch = path.match(/^\/instances\/([^/]+)\/search$/); + if (req.method === "GET" && searchMatch) { + const instance = Resource.Search.get(searchMatch[1]); + const query = url.searchParams.get("q") ?? ""; + const results = await instance.search({ query }); + return Response.json(results); + } + + // Search across multiple instances at once: + // GET /search?q=caching&instances=docs,blog + if (req.method === "GET" && path === "/search") { + const query = url.searchParams.get("q") ?? ""; + const ids = (url.searchParams.get("instances") ?? "").split(","); + if (!ids.length) + return Response.json( + { error: "Provide ?instances=id1,id2" }, + { status: 400 }, + ); + const results = await Resource.Search.search({ + query, + ai_search_options: { instance_ids: ids }, + }); + return Response.json(results); + } + + return Response.json( + { + routes: [ + "GET /instances — list instances", + "POST /instances {id} — create instance", + "DELETE /instances/:name — delete instance", + "PUT /instances/:name/items?filename — upload document", + "GET /instances/:name/search?q= — search one instance", + "GET /search?q=&instances=a,b — search across instances", + ], + }, + { status: 404 }, + ); + }, +}; diff --git a/examples/cloudflare-ai-search-namespace/package.json b/examples/cloudflare-ai-search-namespace/package.json new file mode 100644 index 0000000000..4876011947 --- /dev/null +++ b/examples/cloudflare-ai-search-namespace/package.json @@ -0,0 +1,10 @@ +{ + "name": "cloudflare-ai-search-namespace", + "version": "0.0.0", + "dependencies": { + "sst": "file:../../sdk/js" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260424.1" + } +} diff --git a/examples/cloudflare-ai-search-namespace/sst.config.ts b/examples/cloudflare-ai-search-namespace/sst.config.ts new file mode 100644 index 0000000000..586ac9ae18 --- /dev/null +++ b/examples/cloudflare-ai-search-namespace/sst.config.ts @@ -0,0 +1,38 @@ +/// + +/** + * ## Cloudflare AI Search Namespace + * + * Bind to an AI Search namespace to manage multiple instances at runtime. You + * can create, list, search, and delete instances dynamically — useful for + * multi-tenant apps or admin tools. + * + * Every Cloudflare account has a `default` namespace. You can also create + * custom namespaces to isolate groups of instances. + * + * For a simpler single-instance setup, see the `cloudflare-ai-search` example. + */ +export default $config({ + app(input) { + return { + name: "cloudflare-ai-search-namespace", + removal: input?.stage === "production" ? "retain" : "remove", + home: "cloudflare", + }; + }, + async run() { + const search = new sst.cloudflare.AiSearch("Search", { + namespace: "default", + }); + + const worker = new sst.cloudflare.Worker("Worker", { + handler: "index.ts", + link: [search], + url: true, + }); + + return { + url: worker.url, + }; + }, +}); diff --git a/examples/cloudflare-ai-search-namespace/tsconfig.json b/examples/cloudflare-ai-search-namespace/tsconfig.json new file mode 100644 index 0000000000..9e26dfeeb6 --- /dev/null +++ b/examples/cloudflare-ai-search-namespace/tsconfig.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/examples/cloudflare-ai-search/.gitignore b/examples/cloudflare-ai-search/.gitignore new file mode 100644 index 0000000000..cc54d25a17 --- /dev/null +++ b/examples/cloudflare-ai-search/.gitignore @@ -0,0 +1,3 @@ + +# sst +.sst diff --git a/examples/cloudflare-ai-search/index.ts b/examples/cloudflare-ai-search/index.ts new file mode 100644 index 0000000000..6037af487e --- /dev/null +++ b/examples/cloudflare-ai-search/index.ts @@ -0,0 +1,81 @@ +import { Resource } from "sst"; + +export default { + async fetch(req: Request) { + const url = new URL(req.url); + + // Upload a document: PUT /items?filename=guide.md + if (req.method === "PUT" && url.pathname === "/items") { + const filename = url.searchParams.get("filename"); + if (!filename || !req.body) + return Response.json( + { error: "Provide ?filename= and a request body" }, + { status: 400 }, + ); + const item = await Resource.Search.items.uploadAndPoll( + filename, + req.body, + ); + return Response.json(item, { status: 201 }); + } + + // Search: GET /search?q=how+does+caching+work + if (req.method === "GET" && url.pathname === "/search") { + const query = url.searchParams.get("q") ?? ""; + const results = await Resource.Search.search({ query }); + return Response.json(results); + } + + // Search with filters: GET /search?q=deploy&filter_field=category&filter_value=docs + // Demonstrates metadata filtering with Vectorize filter syntax. + if (req.method === "GET" && url.pathname === "/search/filtered") { + const query = url.searchParams.get("q") ?? ""; + const field = url.searchParams.get("filter_field") ?? ""; + const value = url.searchParams.get("filter_value") ?? ""; + const results = await Resource.Search.search({ + query, + ai_search_options: { + retrieval: { + filters: { [field]: value }, + }, + }, + }); + return Response.json(results); + } + + // Chat completions: POST /chat { "question": "What is Cloudflare?" } + // Returns an AI-generated answer grounded in your indexed content. + if (req.method === "POST" && url.pathname === "/chat") { + const body = (await req.json()) as { question: string }; + const answer = await Resource.Search.chatCompletions({ + messages: [{ role: "user", content: body.question }], + }); + return Response.json(answer); + } + + // Streaming chat: POST /chat/stream { "question": "What is Cloudflare?" } + if (req.method === "POST" && url.pathname === "/chat/stream") { + const body = (await req.json()) as { question: string }; + const stream = await Resource.Search.chatCompletions({ + messages: [{ role: "user", content: body.question }], + stream: true, + }); + return new Response(stream, { + headers: { "content-type": "text/event-stream" }, + }); + } + + return Response.json( + { + routes: [ + "PUT /items?filename= — upload a document (body = content)", + "GET /search?q= — search indexed content", + "GET /search/filtered?q=&... — search with metadata filters", + "POST /chat {question} — AI answer grounded in your content", + "POST /chat/stream {question} — streaming AI answer", + ], + }, + { status: 404 }, + ); + }, +}; diff --git a/examples/cloudflare-ai-search/package.json b/examples/cloudflare-ai-search/package.json new file mode 100644 index 0000000000..21d00e5827 --- /dev/null +++ b/examples/cloudflare-ai-search/package.json @@ -0,0 +1,10 @@ +{ + "name": "cloudflare-ai-search", + "version": "0.0.0", + "dependencies": { + "sst": "file:../../sdk/js" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260424.1" + } +} diff --git a/examples/cloudflare-ai-search/sst.config.ts b/examples/cloudflare-ai-search/sst.config.ts new file mode 100644 index 0000000000..4fb62bc1a6 --- /dev/null +++ b/examples/cloudflare-ai-search/sst.config.ts @@ -0,0 +1,36 @@ +/// + +/** + * ## Cloudflare AI Search + * + * Bind to a single AI Search instance and link it to a worker. The instance + * must already exist in your Cloudflare account — you can create one in the + * dashboard or with the namespace binding example. + * + * Once linked, you can search your indexed content and get AI-generated + * answers using chat completions. + */ +export default $config({ + app(input) { + return { + name: "cloudflare-ai-search", + removal: input?.stage === "production" ? "retain" : "remove", + home: "cloudflare", + }; + }, + async run() { + const search = new sst.cloudflare.AiSearch("Search", { + instance: "my-docs", + }); + + const worker = new sst.cloudflare.Worker("Worker", { + handler: "index.ts", + link: [search], + url: true, + }); + + return { + url: worker.url, + }; + }, +}); diff --git a/examples/cloudflare-ai-search/tsconfig.json b/examples/cloudflare-ai-search/tsconfig.json new file mode 100644 index 0000000000..9e26dfeeb6 --- /dev/null +++ b/examples/cloudflare-ai-search/tsconfig.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/pkg/types/typescript/typescript.go b/pkg/types/typescript/typescript.go index 345493ba30..c1465ae530 100644 --- a/pkg/types/typescript/typescript.go +++ b/pkg/types/typescript/typescript.go @@ -18,15 +18,17 @@ type WarningEvent struct { } var mapping = map[string]string{ - "aiBindings": "Ai", - "r2BucketBindings": "R2Bucket", - "d1DatabaseBindings": "D1Database", - "kvNamespaceBindings": "KVNamespace", - "queueBindings": "Queue", - "serviceBindings": "Service", - "hyperdriveBindings": "Hyperdrive", - "versionMetadataBindings": "WorkerVersionMetadata", - "workflowBindings": "Workflow", + "aiBindings": "Ai", + "r2BucketBindings": "R2Bucket", + "d1DatabaseBindings": "D1Database", + "kvNamespaceBindings": "KVNamespace", + "queueBindings": "Queue", + "serviceBindings": "Service", + "hyperdriveBindings": "Hyperdrive", + "versionMetadataBindings": "WorkerVersionMetadata", + "aiSearchBindings": "AiSearchInstance", + "aiSearchNamespaceBindings": "AiSearchNamespace", + "workflowBindings": "Workflow", } var header = strings.Join([]string{ diff --git a/platform/src/components/cloudflare/ai-search.ts b/platform/src/components/cloudflare/ai-search.ts new file mode 100644 index 0000000000..b4aef67592 --- /dev/null +++ b/platform/src/components/cloudflare/ai-search.ts @@ -0,0 +1,216 @@ +import { ComponentResourceOptions, Output, output } from "@pulumi/pulumi"; +import { Component } from "../component"; +import { Link } from "../link"; +import { Input } from "../input"; +import { binding } from "./binding"; + +export interface AiSearchArgs { + /** + * The name of the AI Search instance to bind to. + * + * Use this when you know which instance you need at deploy time. The instance + * must exist in the `default` namespace. + * + * You must specify either `instance` or `namespace`, but not both. + * + * @example + * ```ts title="sst.config.ts" + * const search = new sst.cloudflare.AiSearch("MySearch", { + * instance: "my-docs-index" + * }); + * ``` + */ + instance?: Input; + /** + * The name of the AI Search namespace to bind to. + * + * Use this when you need access to multiple instances at runtime. You can + * get, create, list, and delete instances within the namespace. + * + * A default namespace is created automatically for every account. If the + * namespace does not exist, it will be created on deploy. + * + * You must specify either `instance` or `namespace`, but not both. + * + * @example + * ```ts title="sst.config.ts" + * const search = new sst.cloudflare.AiSearch("MySearch", { + * namespace: "my-namespace" + * }); + * ``` + */ + namespace?: Input; +} + +/** + * The `AiSearch` component lets you add a [Cloudflare AI Search](https://developers.cloudflare.com/ai-search/) + * binding to your app. + * + * AI Search is a managed search service. Connect a website, an R2 bucket, or upload + * your own documents, and AI Search indexes your content for natural language queries. + * + * There are two types of bindings: + * + * - **Instance binding**: Binds directly to a single AI Search instance. Use this when + * you know which instance you need at deploy time. + * - **Namespace binding**: Binds to a namespace that can contain multiple instances. + * Use this when you need to access or manage multiple instances at runtime. + * + * @example + * + * #### Instance binding + * + * Bind to a specific AI Search instance. + * + * ```ts title="sst.config.ts" + * const search = new sst.cloudflare.AiSearch("MySearch", { + * instance: "my-docs-index" + * }); + * ``` + * + * #### Namespace binding + * + * Bind to a namespace to access multiple instances. + * + * ```ts title="sst.config.ts" + * const search = new sst.cloudflare.AiSearch("MySearch", { + * namespace: "my-namespace" + * }); + * ``` + * + * #### Link to a worker + * + * You can link AI Search to a worker. + * + * ```ts {3} title="sst.config.ts" + * new sst.cloudflare.Worker("MyWorker", { + * handler: "./index.ts", + * link: [search], + * url: true + * }); + * ``` + * + * Once linked, you can use the binding to search your indexed content. + * + * For an **instance binding**, call methods directly: + * + * ```ts title="index.ts" + * const results = await env.MySearch.search({ + * messages: [{ role: "user", content: "What is Cloudflare?" }] + * }); + * ``` + * + * For a **namespace binding**, get an instance handle first: + * + * ```ts title="index.ts" + * const instance = env.MySearch.get("my-docs-index"); + * const results = await instance.search({ + * messages: [{ role: "user", content: "What is Cloudflare?" }] + * }); + * ``` + */ +export class AiSearch extends Component implements Link.Linkable { + private _instance?: Output; + private _namespace?: Output; + + constructor( + name: string, + args: AiSearchArgs, + opts?: ComponentResourceOptions, + ) { + super(__pulumiType, name, args, opts); + + if (args.instance && args.namespace) { + throw new Error( + `Cannot specify both "instance" and "namespace" for AiSearch "${name}". Choose one.`, + ); + } + if (!args.instance && !args.namespace) { + throw new Error( + `Must specify either "instance" or "namespace" for AiSearch "${name}".`, + ); + } + + if (args.instance) { + this._instance = output(args.instance); + } + if (args.namespace) { + this._namespace = output(args.namespace); + } + } + + /** + * The name of the AI Search instance, if using an instance binding. + */ + public get instance() { + return this._instance; + } + + /** + * The name of the AI Search namespace, if using a namespace binding. + */ + public get namespace() { + return this._namespace; + } + + /** + * When you link AI Search, it will be available to the worker and you can + * interact with it using its binding methods. + * + * For an instance binding: + * @example + * ```ts title="index.ts" + * const results = await env.MySearch.search({ + * messages: [{ role: "user", content: "What is Cloudflare?" }] + * }); + * ``` + * + * For a namespace binding: + * @example + * ```ts title="index.ts" + * const instance = env.MySearch.get("my-docs-index"); + * const results = await instance.search({ + * messages: [{ role: "user", content: "What is Cloudflare?" }] + * }); + * ``` + * + * @internal + */ + getSSTLink() { + if (this._instance) { + const instanceName = this._instance; + return { + properties: { + instanceName, + }, + include: [ + binding({ + type: "aiSearchBindings", + properties: { + instanceName, + }, + }), + ], + }; + } + + const namespace = this._namespace!; + return { + properties: { + namespace, + }, + include: [ + binding({ + type: "aiSearchNamespaceBindings", + properties: { + namespace, + }, + }), + ], + }; + } +} + +const __pulumiType = "sst:cloudflare:AiSearch"; +// @ts-expect-error +AiSearch.__pulumiType = __pulumiType; diff --git a/platform/src/components/cloudflare/binding.ts b/platform/src/components/cloudflare/binding.ts index cc901601f4..b277a76e12 100644 --- a/platform/src/components/cloudflare/binding.ts +++ b/platform/src/components/cloudflare/binding.ts @@ -79,6 +79,20 @@ export interface VersionMetadataBinding { properties: Record; } +export interface AiSearchBinding { + type: "aiSearchBindings"; + properties: { + instanceName: Input; + }; +} + +export interface AiSearchNamespaceBinding { + type: "aiSearchNamespaceBindings"; + properties: { + namespace: Input; + }; +} + export interface WorkflowBinding { type: "workflowBindings"; properties: { @@ -99,6 +113,8 @@ export type Binding = | D1DatabaseBinding | HyperdriveBinding | VersionMetadataBinding + | AiSearchBinding + | AiSearchNamespaceBinding | WorkflowBinding; export function binding(input: Binding & {}) { diff --git a/platform/src/components/cloudflare/helpers/wrangler.ts b/platform/src/components/cloudflare/helpers/wrangler.ts index 04658a19cf..99d0f56c50 100644 --- a/platform/src/components/cloudflare/helpers/wrangler.ts +++ b/platform/src/components/cloudflare/helpers/wrangler.ts @@ -52,6 +52,8 @@ export function createWranglerConfig(input: { const hyperdrives: Record[] = []; const services: Record[] = []; const queueProducers: Record[] = []; + const aiSearch: Record[] = []; + const aiSearchNamespaces: Record[] = []; const workflows: Record[] = []; let ai: Record | undefined; let versionMetadata: Record | undefined; @@ -126,6 +128,20 @@ export function createWranglerConfig(input: { binding: link.name, }; break; + case "aiSearchBindings": + aiSearch.push({ + binding: link.name, + instance_name: stringValue(properties.instanceName), + remote: true, + }); + break; + case "aiSearchNamespaceBindings": + aiSearchNamespaces.push({ + binding: link.name, + namespace: stringValue(properties.namespace), + remote: true, + }); + break; case "workflowBindings": workflows.push({ binding: link.name, @@ -167,6 +183,12 @@ export function createWranglerConfig(input: { if (versionMetadata) { config.version_metadata = versionMetadata; } + if (aiSearch.length > 0) { + config.ai_search = aiSearch; + } + if (aiSearchNamespaces.length > 0) { + config.ai_search_namespaces = aiSearchNamespaces; + } if (workflows.length > 0) { config.workflows = workflows; } diff --git a/platform/src/components/cloudflare/index.ts b/platform/src/components/cloudflare/index.ts index 0d571c5893..7acb80b968 100644 --- a/platform/src/components/cloudflare/index.ts +++ b/platform/src/components/cloudflare/index.ts @@ -10,6 +10,7 @@ export * from "./auth"; export * from "./queue"; export * from "./cron"; export * from "./ai"; +export * from "./ai-search"; export * from "./hyperdrive"; export * from "./astro"; export * from "./react-router"; diff --git a/platform/src/components/cloudflare/worker.ts b/platform/src/components/cloudflare/worker.ts index 2b0e4f25be..f74e106f23 100644 --- a/platform/src/components/cloudflare/worker.ts +++ b/platform/src/components/cloudflare/worker.ts @@ -427,6 +427,8 @@ export class Worker extends Component implements Link.Linkable { r2BucketBindings: "r2_bucket", hyperdriveBindings: "hyperdrive", versionMetadataBindings: "version_metadata", + aiSearchBindings: "ai_search", + aiSearchNamespaceBindings: "ai_search_namespace", workflowBindings: "workflow", }[b.binding], name,