1import type {
2 BindingHook,
3 BindingServices,
4} from "@alchemy.run/cloudflare-runtime/core";
5import {
6 Ai,
7 AiSearch,
8 AnalyticsEngine,
9 Artifacts,
10 Assets,
11 Browser,
12 D1,
13 Data,
14 DispatchNamespace,
15 DurableObjectNamespace,
16 Flagship,
17 Hyperdrive,
18 Images,
19 Json,
20 KvNamespace,
21 MtlsCertificate,
22 Pipelines,
23 Queue,
24 R2Bucket,
25 RateLimit,
26 SecretKey,
27 SecretsStore,
28 SendEmail,
29 Service,
30 Stream as StreamSim,
31 Text,
32 Vectorize,
33 VersionMetadata,
34 VpcService,
35 WasmModule,
36 WorkerLoader,
37 Workflows,
38} from "@alchemy.run/cloudflare-runtime/core/bindings";
39import * as Effect from "effect/Effect";
40import * as Redacted from "effect/Redacted";
41import * as Schema from "effect/Schema";
42import { isLocalId } from "../LocalRuntime.ts";
43import { LOCAL_R2_S3_CREDENTIALS, LOCAL_R2_S3_PATH } from "../R2/LocalS3.ts";
44import type { WorkerBinding } from "./WorkerBinding.ts";
45
46export class WorkerValidationError extends Schema.TaggedError<WorkerValidationError>()(
47 "WorkerValidationError",
48 {
49 message: Schema.String,
50 hint: Schema.optional(Schema.String),
51 value: Schema.Unknown,
52 },
53) {}
54
55export const toRuntimeBinding = Effect.fn(function* (
56 b: WorkerBinding,
57 /**
58 * Dev-only channel from the Worker's binding data (see the `devRemote`
59 * member of the Worker binding contract): binding name → opt-out of local
60 * emulation for the capabilities that support it (browser / images /
61 * stream / send_email).
62 */
63 devRemote?: Record<string, boolean>,
64) {
65 const unsupported = () =>
66 new WorkerValidationError({
67 message: `${b.type} bindings are not supported in local mode`,
68 value: b,
69 });
70 switch (b.type) {
71 case "ai":
72 return Ai.remote(b.name);
73 case "ai_search":
74 return AiSearch.remote(b.name, b.instanceName);
75 case "ai_search_namespace":
76 return AiSearch.remoteNamespace(b.name, b.namespace);
77 case "analytics_engine":
78 return AnalyticsEngine.local(b.name, b.dataset);
79 case "artifacts":
80 return Artifacts.remote(b.name, b.namespace);
81 case "assets":
82 return Assets.local(b.name);
83 case "browser":
84 // Local emulation launches a real headless Chrome on this machine and
85 // proxies the Browser Rendering session protocol to its CDP endpoint;
86 // `Alchemy.remote()` opts into the real service instead.
87 return devRemote?.[b.name]
88 ? Browser.remote(b.name)
89 : Browser.local({ binding: b.name });
90 case "d1":
91 // A `dev:` id belongs to a locally-emulated database (local D1
92 // provider); a real id is a live database the dev worker proxies to
93 // (e.g. the resource opted out of emulation via `Alchemy.remote()`).
94 return isLocalId(b.databaseId)
95 ? D1.local({ binding: b.name, id: b.databaseId })
96 : D1.remote(b.name, b.databaseId);
97 case "data_blob":
98 return Data.local(b.name, Buffer.from(b.part));
99 case "dispatch_namespace":
100 return DispatchNamespace.remote({
101 binding: b.name,
102 namespace: b.namespace,
103 });
104 case "durable_object_namespace":
105 return DurableObjectNamespace.local({
106 binding: b.name,
107 className: b.className,
108 scriptName: b.scriptName,
109 uniqueKey:
110 b.namespaceId ??
111 encodeURIComponent(`${b.scriptName!}-${b.className}`),
112 });
113 case "flagship":
114 return Flagship.remote(b.name, b.appId);
115 case "hyperdrive":
116 return Hyperdrive.local(b.name, b.id);
117 case "images":
118 // Local emulation runs transforms via Sharp on this machine and stores
119 // hosted images in a local KV-backed store; `Alchemy.remote()`
120 // opts into the real Images service instead.
121 return devRemote?.[b.name]
122 ? Images.remote(b.name)
123 : Images.local({ binding: b.name });
124 case "inherit":
125 return yield* unsupported();
126 case "json":
127 return Json.local(b.name, b.json);
128 case "kv_namespace":
129 // A `dev:` id belongs to a locally-emulated namespace; a real id is
130 // a live namespace the dev worker proxies to.
131 return isLocalId(b.namespaceId)
132 ? KvNamespace.local({ binding: b.name, id: b.namespaceId })
133 : KvNamespace.remote(b.name, b.namespaceId);
134 case "mtls_certificate":
135 return MtlsCertificate.remote(b.name, b.certificateId);
136 case "pipelines":
137 return Pipelines.remote(b.name, b.pipeline);
138 case "plain_text":
139 return Text.local(b.name, b.text);
140 case "queue": {
141 // A real queueId belongs to an `Alchemy.remote()` queue. Queue bindings
142 // are NOT supported in Cloudflare's remote/preview sessions — a
143 // platform limitation (cloudflare/workers-sdk#9929) — so live
144 // production goes through the deployed shim worker registered at
145 // eval time (see `Queues/QueueShim.ts`): the local binding targets a
146 // forwarder service that relays the queue wire protocol to the shim
147 // over HTTPS with a bearer token.
148 if (b.queueId !== undefined && !isLocalId(b.queueId)) {
149 const url = b.shim?.url;
150 const token = b.shim?.token;
151 if (url === undefined || token === undefined) {
152 // Defensive: binding data produced by current eval always carries
153 // the shim for this mode combination.
154 return yield* new WorkerValidationError({
155 message:
156 `Queue binding "${b.name}" targets a live queue ` +
157 "(Alchemy.remote()) but no producer shim was registered for it — " +
158 "re-deploy, or remove remote() from the queue (local emulation).",
159 value: b,
160 });
161 }
162 return Queue.remote({
163 binding: b.name,
164 queueName: b.queueName,
165 url,
166 token: typeof token === "string" ? token : Redacted.value(token),
167 });
168 }
169 return Queue.local({
170 binding: b.name,
171 queueName: b.queueName,
172 });
173 }
174 case "r2_bucket":
175 // A `dev:`-prefixed bucket name belongs to a locally-emulated bucket
176 // (R2 has no opaque id — the name is the identity); a real name is a
177 // live bucket the dev worker proxies to. Local buckets are also served
178 // on the Worker's local S3 endpoint, so presigned URLs and S3 clients
179 // work in dev without extra configuration.
180 return isLocalId(b.bucketName)
181 ? R2Bucket.local({
182 binding: b.name,
183 id: b.bucketName,
184 s3Credentials: LOCAL_R2_S3_CREDENTIALS,
185 })
186 : R2Bucket.remote(b.name, b.bucketName, b.jurisdiction);
187 case "ratelimit":
188 return RateLimit.local({
189 binding: b.name,
190 simple: b.simple,
191 namespaceId: b.namespaceId,
192 });
193 case "secret_key":
194 // workerd imports the key natively: raw material passes through as
195 // base64, pkcs8/spki base64 DER is PEM-wrapped, and JWK objects are
196 // serialized — all handled by the hook.
197 return SecretKey.local({
198 binding: b.name,
199 format: b.format,
200 algorithm: b.algorithm,
201 usages: b.usages,
202 keyBase64: b.keyBase64,
203 keyJwk: b.keyJwk,
204 });
205 case "secret_text":
206 return Text.local(b.name, b.text);
207 case "secrets_store_secret":
208 // A `dev:` store id belongs to a locally-emulated Secrets Store
209 // (local Store/Secret providers seed the simulator); a real id is a
210 // live secret the dev worker proxies to (`Alchemy.remote()`).
211 return isLocalId(b.storeId)
212 ? SecretsStore.local({
213 binding: b.name,
214 storeId: b.storeId,
215 secretName: b.secretName,
216 })
217 : SecretsStore.remote(b.name, b.storeId, b.secretName);
218 case "send_email":
219 // Local emulation validates and persists sent mail as `.eml` files
220 // under the local storage's `email/` dir; `Alchemy.remote()` on the
221 // descriptor opts into the real Email service instead.
222 return SendEmail[devRemote?.[b.name] ? "remote" : "local"]({
223 binding: b.name,
224 destinationAddress: b.destinationAddress,
225 allowedDestinationAddresses: b.allowedDestinationAddresses,
226 allowedSenderAddresses: b.allowedSenderAddresses,
227 });
228 case "self_service":
229 // A service binding to the worker itself: served in-process by the
230 // runtime's self service (bypasses the assets middleware), matching
231 // the production `service: <own name>` lowering.
232 return Service.self(b.name);
233 case "service":
234 return Service.local({
235 binding: b.name,
236 scriptName: b.service,
237 entrypoint: b.entrypoint,
238 props: b.props,
239 });
240 case "stream":
241 // Local emulation stores videos in a local simulator (no transcoding,
242 // no signed URLs) and serves each video's `preview` URL at
243 // /cdn-cgi/mf/stream/<id>/watch on the dev URL; `Alchemy.remote()`
244 // opts into the real Stream service instead.
245 return devRemote?.[b.name]
246 ? StreamSim.remote(b.name)
247 : StreamSim.local({ binding: b.name });
248 case "text_blob":
249 return Data.local(b.name, Buffer.from(b.part));
250 case "vectorize":
251 return Vectorize.remote(b.name, b.indexName);
252 case "version_metadata":
253 return VersionMetadata.local(b.name);
254 case "vpc_service":
255 // A VPC service tunnels into a private network — nothing to emulate
256 // locally, so the dev worker always proxies to the real service
257 // through the remote-binding bridge.
258 return VpcService.remote(b.name, b.serviceId);
259 case "wasm_module":
260 return WasmModule.local(b.name, Buffer.from(b.part));
261 case "worker_loader":
262 return WorkerLoader.local(b.name);
263 case "workflow":
264 return Workflows.local({
265 binding: b.name,
266 workflowName: b.workflowName,
267 className: b.className,
268 scriptName: b.scriptName,
269 });
270 default:
271 return yield* unsupported();
272 }
273});
274
275/**
276 * Turns the serializable half of a local Worker configuration into the
277 * cloudflare-runtime binding hooks consumed by `Runtime.start`.
278 *
279 * Kept in a leaf module, independent of the provider, so a Vite child
280 * process can reconstruct the hooks after receiving plain binding
281 * descriptors from its parent without evaluating the provider's module
282 * graph. Binding hooks themselves are Effects and cannot cross a process
283 * boundary.
284 *
285 * `config.env` must already be resolved to plain values — `Worker.URL`
286 * sentinels substituted with the actual dev-proxy URL by the caller.
287 */
288export const materializeRuntimeBindings = Effect.fn(function* (
289 config: {
290 name: string;
291 env?: Record<string, unknown>;
292 hasAssets: boolean;
293 bindingDescriptors: WorkerBinding[];
294 /** Binding name → opt-out of local emulation (`Alchemy.remote()`). */
295 devRemote?: Record<string, boolean>;
296 /**
297 * Simulated Cloudflare Access config (`dev: { access: ... }`), lowered
298 * into the `ALCHEMY_DEV_ACCESS` env binding the worker bridge reads to
299 * serve `ctx.access` locally.
300 */
301 devAccess?: { aud?: string; identity?: Record<string, unknown> };
302 },
303 options: {
304 accountId: string;
305 selfUrl: string | undefined;
306 stack: { name: string; stage: string };
307 },
308) {
309 // Resource-backed env entries (e.g. `env: { KV: namespace }`) are
310 // represented by their binding descriptor (same name) — don't ALSO
311 // serialize the resolved attributes as a duplicate json binding.
312 const descriptorNames = new Set(
313 config.bindingDescriptors.map((descriptor) => descriptor.name),
314 );
315 const workerBindings: BindingHook<BindingServices>[] = [
316 Text.local("ALCHEMY_PHASE", "runtime"),
317 Text.local("ALCHEMY_WORKER_NAME", config.name),
318 Text.local("ALCHEMY_STACK_NAME", options.stack.name),
319 Text.local("ALCHEMY_STAGE", options.stack.stage),
320 Text.local("ALCHEMY_CLOUDFLARE_ACCOUNT_ID", options.accountId),
321 ...Object.entries(config.env ?? {})
322 .filter(([key]) => !descriptorNames.has(key))
323 .map(([key, value]) => {
324 const unredacted = Redacted.isRedacted(value)
325 ? Redacted.value(value)
326 : value;
327 return typeof unredacted === "string"
328 ? Text.local(key, unredacted)
329 : Json.local(key, unredacted);
330 }),
331 ...(config.hasAssets ? [Assets.local("ASSETS")] : []),
332 ...(config.devAccess !== undefined
333 ? [Json.local("ALCHEMY_DEV_ACCESS", config.devAccess)]
334 : []),
335 ];
336 for (const descriptor of config.bindingDescriptors) {
337 if (descriptor.type === "self_url") {
338 // Lowered here rather than in `toRuntimeBinding` — only the caller
339 // knows the worker's own dev-proxy URL.
340 workerBindings.push(Text.local(descriptor.name, options.selfUrl!));
341 continue;
342 }
343 if (descriptor.type === "r2_s3_credentials") {
344 // `Cloudflare.R2.S3Credentials` over a `dev:` bucket: credentials for
345 // this Worker's local S3 endpoint (the dev-proxy URL, known only
346 // here), plus a bucket binding so the runtime serves the bucket on
347 // that endpoint.
348 workerBindings.push(
349 Text.local(
350 descriptor.name,
351 JSON.stringify({
352 endpoint: `${options.selfUrl!}${LOCAL_R2_S3_PATH}`,
353 bucketName: descriptor.bucketName,
354 region: "auto",
355 ...LOCAL_R2_S3_CREDENTIALS,
356 }),
357 ),
358 R2Bucket.local({
359 binding: `${descriptor.name}__BUCKET`,
360 id: descriptor.bucketName,
361 s3Credentials: LOCAL_R2_S3_CREDENTIALS,
362 }),
363 );
364 continue;
365 }
366 workerBindings.push(yield* toRuntimeBinding(descriptor, config.devRemote));
367 }
368 return workerBindings;
369});