Skip to content

Commit cf469b6

Browse files
committed
Add web_search binding kind support
Cloudflare Web Search is a new zero-config Workers binding. Declared in wrangler.jsonc as a single object (there is exactly one shared web corpus, so no namespace/instance/setting is required): { "web_search": { "binding": "WEBSEARCH" } } At runtime the binding exposes a single `search()` method that returns URLs and catalog metadata. The binding is always-remote -- Miniflare proxies to the production Web Search service via the remote-bindings transport. Changes in this PR: @cloudflare/workers-utils * environment.ts: `web_search` field (single-object shape) * config.ts: default value * validation.ts: `ConfigBindingFieldName` entry, friendly-name maps, `notInheritable` registration using `validateNamedSimpleBinding`, `safeBindings` entry * worker.ts: `CfWebSearch` interface * types.ts: `web_search` entries in `WorkerMetadataBinding` and `Binding` discriminated unions * map-worker-metadata-bindings.ts: `case "web_search":` arm * tests: validation tests for the new field wrangler * deployment-bundle/create-worker-upload-form.ts: extract + serialise into the upload metadata * api/startDevWorker/utils.ts: config-to-bindings + metadata-to-bindings switches * api/remoteBindings/index.ts: marked always-remote * deploy/config-diffs.ts: `reorderableBindings` and `singleBindingFields` entries * deploy/check-remote-secrets-override.ts: switch case * dev/miniflare/index.ts: `WorkerOptionsBindings` pick, extract, `warnOrError`, pass to miniflare options * type-generation/index.ts: emit `WebSearch` runtime type in both type-generation entry points * user/user.ts: add `websearch:read` OAuth scope * utils/print-bindings.ts: extract + render in startup banner miniflare * plugins/web-search/index.ts: new always-remote proxy plugin * plugins/index.ts: register the plugin in PLUGINS, intersect WorkerOptions, re-export Companion changes land in workerd (Web Search type definitions) and edgeworker-config-service (binding kind + pipeline stage).
1 parent dd4e888 commit cf469b6

19 files changed

Lines changed: 291 additions & 1 deletion

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"miniflare": minor
3+
"wrangler": minor
4+
"@cloudflare/workers-utils": minor
5+
---
6+
7+
Add support for the new `web_search` binding kind.
8+
9+
Cloudflare Web Search is a managed, zero-setup web discovery primitive for
10+
agents and Workers. Declare the binding as a single object in
11+
`wrangler.jsonc`:
12+
13+
```jsonc
14+
{
15+
"web_search": { "binding": "WEBSEARCH" }
16+
}
17+
```
18+
19+
There is exactly one shared web corpus, so there is no namespace, instance,
20+
or other field to specify -- only the variable name. The binding exposes a
21+
single `search()` method that returns URLs and catalog metadata for a
22+
query. Web Search is discovery-only -- to read a result's content the
23+
caller invokes the global `fetch()` API against the result's `url`.
24+
25+
The binding is **always remote** in local development: Miniflare proxies
26+
to the production Web Search service via the remote-bindings transport.
27+
Adds the `websearch:read` OAuth scope to `wrangler login`.

packages/miniflare/src/plugins/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { AI_PLUGIN, AI_PLUGIN_NAME } from "./ai";
22
import { AI_SEARCH_PLUGIN, AI_SEARCH_PLUGIN_NAME } from "./ai-search";
3+
import { WEB_SEARCH_PLUGIN, WEB_SEARCH_PLUGIN_NAME } from "./web-search";
34
import {
45
ANALYTICS_ENGINE_PLUGIN,
56
ANALYTICS_ENGINE_PLUGIN_NAME,
@@ -66,6 +67,7 @@ export const PLUGINS = {
6667
[ANALYTICS_ENGINE_PLUGIN_NAME]: ANALYTICS_ENGINE_PLUGIN,
6768
[AI_PLUGIN_NAME]: AI_PLUGIN,
6869
[AI_SEARCH_PLUGIN_NAME]: AI_SEARCH_PLUGIN,
70+
[WEB_SEARCH_PLUGIN_NAME]: WEB_SEARCH_PLUGIN,
6971
[BROWSER_RENDERING_PLUGIN_NAME]: BROWSER_RENDERING_PLUGIN,
7072
[DISPATCH_NAMESPACE_PLUGIN_NAME]: DISPATCH_NAMESPACE_PLUGIN,
7173
[IMAGES_PLUGIN_NAME]: IMAGES_PLUGIN,
@@ -134,6 +136,7 @@ export type WorkerOptions = z.input<typeof CORE_PLUGIN.options> &
134136
z.input<typeof ANALYTICS_ENGINE_PLUGIN.options> &
135137
z.input<typeof AI_PLUGIN.options> &
136138
z.input<typeof AI_SEARCH_PLUGIN.options> &
139+
z.input<typeof WEB_SEARCH_PLUGIN.options> &
137140
z.input<typeof BROWSER_RENDERING_PLUGIN.options> &
138141
z.input<typeof DISPATCH_NAMESPACE_PLUGIN.options> &
139142
z.input<typeof IMAGES_PLUGIN.options> &
@@ -224,6 +227,7 @@ export * from "./email";
224227
export * from "./analytics-engine";
225228
export * from "./ai";
226229
export * from "./ai-search";
230+
export * from "./web-search";
227231
export * from "./browser-rendering";
228232
export * from "./dispatch-namespace";
229233
export * from "./images";
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { z } from "zod";
2+
import {
3+
getUserBindingServiceName,
4+
ProxyNodeBinding,
5+
remoteProxyClientWorker,
6+
} from "../shared";
7+
import type { Plugin, RemoteProxyConnectionString } from "../shared";
8+
9+
const WebSearchEntrySchema = z.object({
10+
remoteProxyConnectionString: z
11+
.custom<RemoteProxyConnectionString>()
12+
.optional(),
13+
});
14+
15+
export const WebSearchOptionsSchema = z.object({
16+
webSearch: z.record(WebSearchEntrySchema).optional(),
17+
});
18+
19+
export const WEB_SEARCH_PLUGIN_NAME = "web-search";
20+
21+
const WEB_SEARCH_SCOPE = "web-search";
22+
23+
export const WEB_SEARCH_PLUGIN: Plugin<typeof WebSearchOptionsSchema> = {
24+
options: WebSearchOptionsSchema,
25+
async getBindings(options) {
26+
const bindings: {
27+
name: string;
28+
service: { name: string };
29+
}[] = [];
30+
31+
for (const [bindingName, entry] of Object.entries(
32+
options.webSearch ?? {}
33+
)) {
34+
bindings.push({
35+
name: bindingName,
36+
service: {
37+
name: getUserBindingServiceName(
38+
WEB_SEARCH_SCOPE,
39+
bindingName,
40+
entry.remoteProxyConnectionString
41+
),
42+
},
43+
});
44+
}
45+
46+
return bindings;
47+
},
48+
getNodeBindings(options: z.infer<typeof WebSearchOptionsSchema>) {
49+
const nodeBindings: Record<string, ProxyNodeBinding> = {};
50+
51+
for (const bindingName of Object.keys(options.webSearch ?? {})) {
52+
nodeBindings[bindingName] = new ProxyNodeBinding();
53+
}
54+
55+
return nodeBindings;
56+
},
57+
async getServices({ options }) {
58+
const services: {
59+
name: string;
60+
worker: ReturnType<typeof remoteProxyClientWorker>;
61+
}[] = [];
62+
63+
for (const [bindingName, entry] of Object.entries(
64+
options.webSearch ?? {}
65+
)) {
66+
services.push({
67+
name: getUserBindingServiceName(
68+
WEB_SEARCH_SCOPE,
69+
bindingName,
70+
entry.remoteProxyConnectionString
71+
),
72+
worker: remoteProxyClientWorker(
73+
entry.remoteProxyConnectionString,
74+
bindingName
75+
),
76+
});
77+
}
78+
79+
return services;
80+
},
81+
};

packages/workers-utils/src/config/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,7 @@ export const defaultWranglerConfig: Config = {
336336
vectorize: [],
337337
ai_search_namespaces: [],
338338
ai_search: [],
339+
web_search: undefined,
339340
hyperdrive: [],
340341
workflows: [],
341342
secrets_store_secrets: [],

packages/workers-utils/src/config/environment.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1014,6 +1014,26 @@ export interface EnvironmentNonInheritable {
10141014
remote?: boolean;
10151015
}[];
10161016

1017+
/**
1018+
* Cloudflare Web Search binding. There is exactly one shared web corpus, so the
1019+
* binding is zero-config -- only the variable name is required, declared as a
1020+
* single object (not an array).
1021+
*
1022+
* NOTE: This field is not automatically inherited from the top level environment,
1023+
* and so must be specified in every named environment.
1024+
*
1025+
* @default {}
1026+
* @nonInheritable
1027+
*/
1028+
web_search:
1029+
| {
1030+
/** The binding name used to refer to Web Search in the Worker. */
1031+
binding: string;
1032+
/** Whether the Web Search binding should be remote or not in local development */
1033+
remote?: boolean;
1034+
}
1035+
| undefined;
1036+
10171037
/**
10181038
* Specifies Hyperdrive configs that are bound to this Worker environment.
10191039
*

packages/workers-utils/src/config/validation.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export type ConfigBindingFieldName =
8181
| "vectorize"
8282
| "ai_search_namespaces"
8383
| "ai_search"
84+
| "web_search"
8485
| "hyperdrive"
8586
| "r2_buckets"
8687
| "logfwdr"
@@ -122,6 +123,7 @@ export const friendlyBindingNames: Record<ConfigBindingFieldName, string> = {
122123
vectorize: "Vectorize Index",
123124
ai_search_namespaces: "AI Search Namespace",
124125
ai_search: "AI Search Instance",
126+
web_search: "Web Search",
125127
hyperdrive: "Hyperdrive Config",
126128
r2_buckets: "R2 Bucket",
127129
logfwdr: "logfwdr",
@@ -178,6 +180,7 @@ const bindingTypeFriendlyNames: Record<Binding["type"], string> = {
178180
vectorize: "Vectorize Index",
179181
ai_search_namespace: "AI Search Namespace",
180182
ai_search: "AI Search Instance",
183+
web_search: "Web Search",
181184
hyperdrive: "Hyperdrive Config",
182185
service: "Worker",
183186
fetcher: "Service Binding",
@@ -1727,6 +1730,16 @@ function normalizeAndValidateEnvironment(
17271730
validateBindingArray(envName, validateAISearchBinding),
17281731
[]
17291732
),
1733+
web_search: notInheritable(
1734+
diagnostics,
1735+
topLevelEnv,
1736+
rawConfig,
1737+
rawEnv,
1738+
envName,
1739+
"web_search",
1740+
validateNamedSimpleBinding(envName),
1741+
undefined
1742+
),
17301743
hyperdrive: notInheritable(
17311744
diagnostics,
17321745
topLevelEnv,
@@ -2982,6 +2995,7 @@ const validateUnsafeBinding: ValidatorFn = (diagnostics, field, value) => {
29822995
"ai",
29832996
"ai_search_namespace",
29842997
"ai_search",
2998+
"web_search",
29852999
"kv_namespace",
29863000
"durable_object_namespace",
29873001
"d1_database",

packages/workers-utils/src/map-worker-metadata-bindings.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,13 @@ export function mapWorkerMetadataBindings(
288288
},
289289
];
290290
break;
291+
case "web_search":
292+
{
293+
configObj.web_search = {
294+
binding: binding.name,
295+
};
296+
}
297+
break;
291298
case "hyperdrive":
292299
configObj.hyperdrive = [
293300
...(configObj.hyperdrive ?? []),

packages/workers-utils/src/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import type {
3939
CfVectorize,
4040
CfVpcNetwork,
4141
CfVpcService,
42+
CfWebSearch,
4243
CfWorkerLoader,
4344
CfWorkflow,
4445
} from "./worker";
@@ -72,6 +73,7 @@ export type WorkerMetadataBinding =
7273
| { type: "data_blob"; name: string; part: string }
7374
| { type: "ai_search_namespace"; name: string; namespace: string }
7475
| { type: "ai_search"; name: string; instance_name: string }
76+
| { type: "web_search"; name: string }
7577
| { type: "kv_namespace"; name: string; namespace_id: string; raw?: boolean }
7678
| { type: "media"; name: string }
7779
| {
@@ -328,6 +330,7 @@ export type Binding =
328330
| ({ type: "vectorize" } & BindingOmit<CfVectorize>)
329331
| ({ type: "ai_search_namespace" } & BindingOmit<CfAISearchNamespace>)
330332
| ({ type: "ai_search" } & BindingOmit<CfAISearch>)
333+
| ({ type: "web_search" } & BindingOmit<CfWebSearch>)
331334
| ({ type: "hyperdrive" } & BindingOmit<CfHyperdrive>)
332335
| ({ type: "service" } & BindingOmit<CfService>)
333336
| { type: "fetcher"; fetcher: ServiceFetch }

packages/workers-utils/src/worker.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,11 @@ export interface CfAISearch {
242242
remote?: boolean;
243243
}
244244

245+
export interface CfWebSearch {
246+
binding: string;
247+
remote?: boolean;
248+
}
249+
245250
export interface CfSecretsStoreSecrets {
246251
binding: string;
247252
store_id: string;

packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2018,6 +2018,50 @@ describe("normalizeAndValidateConfig()", () => {
20182018
});
20192019
});
20202020

2021+
describe("[web_search]", () => {
2022+
it("should accept a valid web_search binding", ({ expect }) => {
2023+
const { diagnostics } = normalizeAndValidateConfig(
2024+
{ web_search: { binding: "WEBSEARCH" } } as RawConfig,
2025+
undefined,
2026+
undefined,
2027+
{ env: undefined }
2028+
);
2029+
2030+
expect(diagnostics.hasErrors()).toBe(false);
2031+
expect(diagnostics.hasWarnings()).toBe(false);
2032+
});
2033+
2034+
it("should error if web_search is an array", ({ expect }) => {
2035+
const { diagnostics } = normalizeAndValidateConfig(
2036+
{ web_search: [] } as unknown as RawConfig,
2037+
undefined,
2038+
undefined,
2039+
{ env: undefined }
2040+
);
2041+
2042+
expect(diagnostics.hasWarnings()).toBe(false);
2043+
expect(diagnostics.renderErrors()).toMatchInlineSnapshot(`
2044+
"Processing wrangler configuration:
2045+
- The field "web_search" should be an object but got []."
2046+
`);
2047+
});
2048+
2049+
it("should error if web_search has no binding name", ({ expect }) => {
2050+
const { diagnostics } = normalizeAndValidateConfig(
2051+
{ web_search: {} } as unknown as RawConfig,
2052+
undefined,
2053+
undefined,
2054+
{ env: undefined }
2055+
);
2056+
2057+
expect(diagnostics.hasWarnings()).toBe(false);
2058+
expect(diagnostics.renderErrors()).toMatchInlineSnapshot(`
2059+
"Processing wrangler configuration:
2060+
- binding should have a string "binding" field."
2061+
`);
2062+
});
2063+
});
2064+
20212065
// Vectorize
20222066
describe("[vectorize]", () => {
20232067
it("should error if vectorize is an object", ({ expect }) => {

0 commit comments

Comments
 (0)