| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Redacted from "effect/Redacted"; |
| 3 | import type { Json } from "effect/Schema"; |
| 4 | import type { InputProps } from "../../Input.ts"; |
| 5 | import * as Output from "../../Output.ts"; |
| 6 | import type { ResourceBinding } from "../../Resource.ts"; |
| 7 | import * as Namespace from "../../Namespace.ts"; |
| 8 | import { defaultProviderMode } from "../../ProviderMode.ts"; |
| 9 | import { isYieldableEffectLike } from "../../Util/effect.ts"; |
| 10 | import { |
| 11 | Application, |
| 12 | isApplication, |
| 13 | type Application as AccessApplication, |
| 14 | } from "../Access/Application.ts"; |
| 15 | import { isAiGateway } from "../AI/Gateway.ts"; |
| 16 | import { isSearchInstance } from "../AI/SearchInstance.ts"; |
| 17 | import { isSearchNamespace } from "../AI/SearchNamespace.ts"; |
| 18 | import { isDataset } from "../AnalyticsEngine/Dataset.ts"; |
| 19 | import { isNamespace } from "../Artifacts/Namespace.ts"; |
| 20 | import type { Container } from "../Containers/Container.ts"; |
| 21 | import type { ContainerApplication } from "../Containers/ContainerApplication.ts"; |
| 22 | import { isDatabase } from "../D1/Database.ts"; |
| 23 | import { isSendEmail } from "../Email/SendEmail.ts"; |
| 24 | import { isApp } from "../Flagship/App.ts"; |
| 25 | import { getHyperdriveDevOrigin } from "../Hyperdrive/ConnectBinding.ts"; |
| 26 | import { isHyperdriveConnection } from "../Hyperdrive/Connection.ts"; |
| 27 | import { isImages } from "../Images/Images.ts"; |
| 28 | import { isNamespace as isKVNamespace } from "../KV/Namespace.ts"; |
| 29 | import { isLegacyPipeline } from "../Pipelines/LegacyPipeline.ts"; |
| 30 | import { isStream as isPipelinesStream } from "../Pipelines/Stream.ts"; |
| 31 | import { isQueue } from "../Queues/Queue.ts"; |
| 32 | import { maybeQueueShim } from "../Queues/QueueShim.ts"; |
| 33 | import { isBucket } from "../R2/Bucket.ts"; |
| 34 | import { |
| 35 | bindS3Credentials, |
| 36 | isS3Credentials, |
| 37 | resolveBucket, |
| 38 | } from "../R2/S3CredentialsBinding.ts"; |
| 39 | import { isSecret } from "../SecretsStore/Secret.ts"; |
| 40 | import { isStream } from "../Stream/Stream.ts"; |
| 41 | import { isIndex } from "../Vectorize/VectorizeIndex.ts"; |
| 42 | import { isVpcService } from "../VpcService/VpcService.ts"; |
| 43 | import type { VpcServiceLookup } from "../VpcService/VpcServiceLookup.ts"; |
| 44 | import { isDispatchNamespace } from "../WorkersForPlatforms/DispatchNamespace.ts"; |
| 45 | import { |
| 46 | isWorkflowLike, |
| 47 | WorkflowResource, |
| 48 | type WorkflowBinding, |
| 49 | } from "../Workflows/Workflow.ts"; |
| 50 | import { |
| 51 | asScriptNameOutput, |
| 52 | makeWorkflowName, |
| 53 | } from "../Workflows/WorkflowName.ts"; |
| 54 | import { isAI } from "./AI.ts"; |
| 55 | import { isAssets } from "./Assets.ts"; |
| 56 | import { isBinding as isWorkerOnlyBinding } from "./Binding.ts"; |
| 57 | import { isBrowser } from "./Browser.ts"; |
| 58 | import { |
| 59 | isDurableObjectLike, |
| 60 | normalizeTransferredFrom, |
| 61 | } from "./DurableObject.ts"; |
| 62 | import { isRateLimit } from "./RateLimit.ts"; |
| 63 | import { isSecretKey } from "./SecretKey.ts"; |
| 64 | import { isVersionMetadata } from "./VersionMetadata.ts"; |
| 65 | import type { WorkerBindingProps } from "./Worker.ts"; |
| 66 | import { |
| 67 | isSelf, |
| 68 | isSelfUrl, |
| 69 | isWorker, |
| 70 | type Worker, |
| 71 | type WorkerObservability, |
| 72 | type WorkerProps, |
| 73 | } from "./Worker.ts"; |
| 74 | import type { WorkerAccessApplication } from "./WorkerAccess.ts"; |
| 75 | import type { WorkerBinding, WorkerBindingResource } from "./WorkerBinding.ts"; |
| 76 | import { isWorkerEntrypoint } from "./WorkerEntrypoint.ts"; |
| 77 | import { isWorkerLoader } from "./WorkerLoader.ts"; |
| 78 | |
| 79 | export const bindWorkerAsyncBindings = Effect.fn(function* ( |
| 80 | resource: Worker, |
| 81 | props: InputProps<WorkerProps<WorkerBindingProps>>, |
| 82 | ) { |
| 83 | if (globalThis.__ALCHEMY_RUNTIME__) return; |
| 84 | |
| 85 | // Access enrollment (`access` prop): push this Worker's |
| 86 | // `worker`/`preview_worker` destinations onto the application's binding |
| 87 | // contract. The application deploys with — and converges on — every |
| 88 | // enrolled Worker's destinations, including at create time, where |
| 89 | // Cloudflare requires a self-hosted app to be born with a domain or |
| 90 | // destinations. |
| 91 | // |
| 92 | // Skipped when this Worker runs locally (`alchemy dev`): a local worker |
| 93 | // has no cloud script (no immutable ID to enroll), and Access cannot |
| 94 | // front a localhost URL — the `dev.access` stub covers the runtime |
| 95 | // instead. A Worker opted out via `Alchemy.remote()` enrolls normally. |
| 96 | const accessHostMode = resource.Mode ?? (yield* defaultProviderMode); |
| 97 | if (props.access && accessHostMode !== "local") { |
| 98 | const accessInput = props.access; |
| 99 | // The shared form is the application itself — as the module-scope |
| 100 | // declaration Effect (`const App = Cloudflare.Access.Application(...)`) |
| 101 | // or an already-yielded resource. Resolve the yieldable spelling first, |
| 102 | // then discriminate. |
| 103 | const access = ( |
| 104 | isYieldableEffectLike(accessInput) && !Output.isOutput(accessInput) |
| 105 | ? yield* accessInput as Effect.Effect<unknown> |
| 106 | : accessInput |
| 107 | ) as AccessApplication | InputProps<WorkerAccessApplication>; |
| 108 | const application = isApplication(access) |
| 109 | ? access |
| 110 | : // Dedicated form: declare an application owned by this Worker — |
| 111 | // per-Worker Access configuration means a per-Worker application |
| 112 | // (Cloudflare attaches policies to applications, not Workers). It |
| 113 | // lives in the Worker's namespace: `<Worker>/Access`. |
| 114 | yield* Application("Access", { |
| 115 | type: "self_hosted", |
| 116 | name: access.name, |
| 117 | policies: access.policies, |
| 118 | sessionDuration: access.sessionDuration, |
| 119 | allowedIdps: access.allowedIdps, |
| 120 | autoRedirectToIdentity: access.autoRedirectToIdentity, |
| 121 | appLauncherVisible: access.appLauncherVisible, |
| 122 | }).pipe(Namespace.push(resource.LogicalId)); |
| 123 | const previews = isApplication(access) || access.previews !== false; |
| 124 | yield* application.bind(`enroll:${resource.FQN}`, { |
| 125 | destinations: [ |
| 126 | { type: "worker", workerId: resource.workerId }, |
| 127 | ...(previews |
| 128 | ? [ |
| 129 | { |
| 130 | type: "preview_worker" as const, |
| 131 | workerId: resource.workerId, |
| 132 | }, |
| 133 | ] |
| 134 | : []), |
| 135 | ], |
| 136 | }); |
| 137 | } |
| 138 | const env: Record<string, unknown> = props.assets |
| 139 | ? { ASSETS: { kind: "Cloudflare.Workers.Assets" } } |
| 140 | : {}; |
| 141 | if (props.env) { |
| 142 | for (const bindingName in props.env) { |
| 143 | // @ts-expect-error |
| 144 | const bindingEff = props.env?.[bindingName] as |
| 145 | | WorkerBindingResource |
| 146 | | Effect.Effect<WorkerBindingResource>; |
| 147 | // A Container bound directly in `env` declares a container-backed |
| 148 | // Durable Object class: the Container IS the DO namespace binding plus |
| 149 | // its ContainerApplication. Handled before the generic Effect |
| 150 | // resolution below — yielding the Container class would resolve its |
| 151 | // *started instance* tag, which only exists inside a Durable Object. |
| 152 | if (isContainerDecl(bindingEff)) { |
| 153 | env[bindingName] = bindingEff; |
| 154 | yield* bindContainerClass(resource, bindingName, bindingEff); |
| 155 | continue; |
| 156 | } |
| 157 | // `Cloudflare.R2.S3Credentials` is also an Effect (its Effect-native |
| 158 | // form resolves to a runtime accessor): bind its deploy-time half under |
| 159 | // the env key instead of yielding it. |
| 160 | if (isS3Credentials(bindingEff)) { |
| 161 | env[bindingName] = bindingEff; |
| 162 | yield* bindS3Credentials( |
| 163 | resource, |
| 164 | bindingName, |
| 165 | yield* resolveBucket(bindingEff.bucket), |
| 166 | bindingEff.access, |
| 167 | ); |
| 168 | continue; |
| 169 | } |
| 170 | // Bindings can be passed as a plain resource value, an Effect that |
| 171 | // yields a resource, or an effect-class (e.g. a `Cloudflare.Worker` |
| 172 | // class). Resolve the yieldable forms before deriving binding metadata. |
| 173 | // Avoid yielding outputs as this requires `RuntimeContext`; |
| 174 | // allow the engine to resolve them instead. |
| 175 | const binding = ( |
| 176 | isYieldableEffectLike(bindingEff) && !Output.isOutput(bindingEff) |
| 177 | ? yield* bindingEff as Effect.Effect<unknown> |
| 178 | : bindingEff |
| 179 | ) as WorkerBindingResource; |
| 180 | env[bindingName] = binding; |
| 181 | |
| 182 | // Queue producer bindings may need the dev-mode remote-producer shim |
| 183 | // (a LOCAL worker binding a LIVE queue): `maybeQueueShim` registers |
| 184 | // the shim worker + token resources at eval time — the same idiom as |
| 185 | // the WorkflowResource registration below — and contributes their |
| 186 | // outputs to the binding data. |
| 187 | if (isQueue(binding)) { |
| 188 | const shim = yield* maybeQueueShim(binding, resource); |
| 189 | yield* resource.bind`${bindingName}`({ |
| 190 | bindings: [ |
| 191 | { |
| 192 | type: "queue", |
| 193 | name: bindingName, |
| 194 | queueName: binding.queueName, |
| 195 | // Alchemy-only mode discriminator for dev (stripped before |
| 196 | // upload). |
| 197 | queueId: binding.queueId, |
| 198 | ...(shim ? { shim } : {}), |
| 199 | }, |
| 200 | ], |
| 201 | }); |
| 202 | continue; |
| 203 | } |
| 204 | |
| 205 | const bindingMeta: |
| 206 | | BindingSpec |
| 207 | | Output.Output<WorkerBinding> |
| 208 | | undefined = toBinding(bindingName, binding); |
| 209 | |
| 210 | if (Output.isOutput(bindingMeta)) { |
| 211 | // A whole-resource Output resolves to the resource's raw attributes; |
| 212 | // uploading those as a plaintext json env var is never intended. |
| 213 | // This cannot be rejected at compile time (`Input<T>` admits any |
| 214 | // Output whose A is structurally Json), so this guard is the |
| 215 | // enforcement point. |
| 216 | if (Output.isResourceExpr(binding) || Output.isRefExpr(binding)) { |
| 217 | return yield* Effect.die( |
| 218 | `Cannot bind whole-resource Output "${bindingName}": pass the resource (or a typed ref) directly, or bind one of its attribute Outputs`, |
| 219 | ); |
| 220 | } |
| 221 | // Deferred classification (see `toBinding`'s Output arm): the engine |
| 222 | // resolves the Output inside the binding data before the Worker |
| 223 | // provider reads it. |
| 224 | yield* resource.bind`${bindingName}`({ bindings: [bindingMeta] }); |
| 225 | continue; |
| 226 | } |
| 227 | |
| 228 | if (bindingMeta) { |
| 229 | let resolvedBindingMeta: InputProps<WorkerBinding> = bindingMeta; |
| 230 | |
| 231 | if (isWorkflowLike(binding)) { |
| 232 | const className = binding.className ?? binding.name; |
| 233 | const scriptName = binding.scriptName ?? resource.workerName; |
| 234 | const workflowName = |
| 235 | binding.workflowName ?? makeWorkflowName(scriptName, className); |
| 236 | resolvedBindingMeta = { |
| 237 | ...resolvedBindingMeta, |
| 238 | workflowName, |
| 239 | }; |
| 240 | |
| 241 | // A locally-hosted Workflow (no `scriptName`) must be registered |
| 242 | // with Cloudflare via `putWorkflow` once the host Worker exists. |
| 243 | // Cross-script references are binding-only; both sides use the |
| 244 | // explicit physical name when supplied, or derive the same default |
| 245 | // from the host script and class. |
| 246 | const workflow = binding.scriptName |
| 247 | ? undefined |
| 248 | : yield* WorkflowResource(binding.name, { |
| 249 | workflowName: binding.workflowName, |
| 250 | className, |
| 251 | scriptName: |
| 252 | binding.workflowName === undefined |
| 253 | ? resource.workerName |
| 254 | : undefined, |
| 255 | limits: binding.limits, |
| 256 | schedules: binding.schedules, |
| 257 | }); |
| 258 | if (workflow) { |
| 259 | // Host linkage must not block the named identity's adoption probe. |
| 260 | if (binding.workflowName !== undefined) { |
| 261 | yield* workflow.bind`${resource}`({ |
| 262 | scriptName: resource.workerName, |
| 263 | }); |
| 264 | } |
| 265 | resolvedBindingMeta = { |
| 266 | ...resolvedBindingMeta, |
| 267 | workflowName: binding.workflowName ?? workflow.workflowName, |
| 268 | }; |
| 269 | } |
| 270 | |
| 271 | // Local outputs depend on registration and preserve deployed identity. |
| 272 | env[bindingName] = { |
| 273 | kind: binding.kind, |
| 274 | name: binding.name, |
| 275 | className, |
| 276 | workflowName: workflow |
| 277 | ? workflow.workflowName |
| 278 | : Output.asOutput(workflowName), |
| 279 | scriptName: workflow |
| 280 | ? workflow.scriptName |
| 281 | : asScriptNameOutput(scriptName), |
| 282 | } satisfies WorkflowBinding; |
| 283 | } |
| 284 | |
| 285 | yield* resource.bind`${bindingName}`({ |
| 286 | bindings: [resolvedBindingMeta], |
| 287 | hyperdrives: isHyperdriveConnection(binding) |
| 288 | ? getHyperdriveDevOrigin(binding) |
| 289 | : undefined, |
| 290 | // Dev-only local-emulation opt-out channel (like `hyperdrives`): |
| 291 | // worker-only bindings and `SendEmail` descriptors piped through |
| 292 | // `Alchemy.remote()` carry the internal `devRemote` flag on their |
| 293 | // binding value; contribute it as binding data so the wire binding |
| 294 | // stays pure. |
| 295 | devRemote: |
| 296 | (isWorkerOnlyBinding(binding) || isSendEmail(binding)) && |
| 297 | binding.devRemote |
| 298 | ? { [bindingName]: true } |
| 299 | : undefined, |
| 300 | }); |
| 301 | } else { |
| 302 | // Defensive catch-all: `toBinding` currently always classifies |
| 303 | // (its final arm is the `json` fallback), but keep the branch so a |
| 304 | // future gap fails loudly instead of silently skipping the binding. |
| 305 | return yield* Effect.die(`Unknown binding type: ${bindingName}`); |
| 306 | } |
| 307 | } |
| 308 | } |
| 309 | if (resource.Props?.isExternal === true) { |
| 310 | Object.assign(resource, { env }); |
| 311 | } |
| 312 | }); |
| 313 | |
| 314 | /** |
| 315 | * Structural check for a `Cloudflare.Container` class declaration. Keyed on |
| 316 | * the `~alchemy/Container/ClassName` marker (rather than importing from |
| 317 | * `Containers/Container.ts`) to avoid a value-level import cycle through |
| 318 | * `ContainerPlatform` → `Worker.ts` → this module. |
| 319 | * |
| 320 | * A Container declaration is Effect-shaped (yielding it resolves the |
| 321 | * *started instance* tag, which only exists inside a Durable Object), so |
| 322 | * every env-resolution site must check this before `Effect.isEffect`. |
| 323 | */ |
| 324 | export const isContainerDecl = (value: unknown): value is Container.Decl.Any => |
| 325 | (typeof value === "function" || typeof value === "object") && |
| 326 | value !== null && |
| 327 | "~alchemy/Container/ClassName" in value; |
| 328 | |
| 329 | /** |
| 330 | * Structural check for a yielded {@link ContainerApplication} resource |
| 331 | * instance (same import-cycle note as above). |
| 332 | */ |
| 333 | const isContainerApplicationResource = ( |
| 334 | value: unknown, |
| 335 | ): value is ContainerApplication => |
| 336 | typeof value === "object" && |
| 337 | value !== null && |
| 338 | (value as { Type?: unknown }).Type === "Cloudflare.Container"; |
| 339 | |
| 340 | /** |
| 341 | * Wire a Container bound directly on an async Worker's `env` |
| 342 | * (`env: { Sandbox: Cloudflare.Container("Sandbox", { image }) }`). Mirrors |
| 343 | * v1 alchemy, where a Container binding is a Durable Object namespace plus |
| 344 | * its ContainerApplication in one declaration: |
| 345 | * |
| 346 | * 1. emit the `durable_object_namespace` binding for the class, |
| 347 | * 2. attach the class's namespace id to the ContainerApplication, and |
| 348 | * 3. contribute `containers: [{ className }]` binding data so the Worker's |
| 349 | * script metadata marks the class as container-backed. |
| 350 | * |
| 351 | * The namespace id only exists once the Worker uploads, while the Worker's |
| 352 | * metadata needs the container class — the same circularity as the |
| 353 | * Effect-native path (`ContainerPlatform.bind`), resolved through bindings + |
| 354 | * precreate. |
| 355 | */ |
| 356 | const bindContainerClass = Effect.fn(function* ( |
| 357 | resource: Worker, |
| 358 | bindingName: string, |
| 359 | decl: Container.Decl.Any, |
| 360 | ) { |
| 361 | // Effect-valued container props (the shape that threads a sibling |
| 362 | // resource's outputs into `env`) can only surface `className` here, once |
| 363 | // the props Effect runs. |
| 364 | const declaredClassName = decl["~alchemy/Container/ClassName"]; |
| 365 | const className = |
| 366 | (Effect.isEffect(declaredClassName) |
| 367 | ? yield* declaredClassName |
| 368 | : declaredClassName) ?? bindingName; |
| 369 | // Resolve the ContainerApplication resource declaration carried on the |
| 370 | // class. An effectful (`main`) container has no application declaration of |
| 371 | // its own here (it is created by its `.make()` Layer inside a Durable |
| 372 | // Object host), so it cannot back an async class. |
| 373 | const declaration = (decl as { Application?: unknown }).Application; |
| 374 | const application = |
| 375 | isYieldableEffectLike(declaration) && !Output.isOutput(declaration) |
| 376 | ? yield* declaration as Effect.Effect<unknown> |
| 377 | : undefined; |
| 378 | if (!isContainerApplicationResource(application)) { |
| 379 | return yield* Effect.die( |
| 380 | `Worker binding '${bindingName}' is a Container without a deployable image. Declare the container with props (image, or context/dockerfile) to bind it on an async Worker — effectful (main) containers require an Effect-native Durable Object host.`, |
| 381 | ); |
| 382 | } |
| 383 | // Both halves of the Worker's side describe the same env entry, so they |
| 384 | // share one `sid`. Binding rows are collapsed by sid (last write wins), so |
| 385 | // splitting them across two `bind` calls silently drops the namespace |
| 386 | // binding whenever the two sids coincide — e.g. the env key and the |
| 387 | // Container's logical id match (`Sandbox: Container("Sandbox", …)`). The |
| 388 | // Worker then uploads the Container declaration itself as a `json` binding |
| 389 | // and `env.NAME` is not a DO namespace at runtime. |
| 390 | yield* resource.bind`${bindingName}`({ |
| 391 | bindings: [ |
| 392 | { |
| 393 | type: "durable_object_namespace", |
| 394 | name: bindingName, |
| 395 | className, |
| 396 | }, |
| 397 | ], |
| 398 | containers: [ |
| 399 | { |
| 400 | className, |
| 401 | dev: application.dev, |
| 402 | hash: application.hash.pipe(Output.map((h) => h?.image)), |
| 403 | }, |
| 404 | ], |
| 405 | }); |
| 406 | yield* application.bind`${bindingName}`({ |
| 407 | durableObjects: { |
| 408 | namespaceId: resource.durableObjectNamespaces.pipe( |
| 409 | Output.map((namespaces) => namespaces?.[className]), |
| 410 | ), |
| 411 | }, |
| 412 | }); |
| 413 | }); |
| 414 | |
| 415 | type BindingSpec = InputProps<WorkerBinding>; |
| 416 | |
| 417 | /** |
| 418 | * Classify a plain env value (string / Redacted / Json) into its wire |
| 419 | * binding. Called by {@link toBinding} both eagerly (literal values) and |
| 420 | * deferred (the resolved value of an Output env entry). |
| 421 | */ |
| 422 | const toValueBinding = ( |
| 423 | bindingName: string, |
| 424 | value: Json | Redacted.Redacted<Json>, |
| 425 | ): WorkerBinding => { |
| 426 | if (typeof value === "string") { |
| 427 | return { |
| 428 | type: "plain_text", |
| 429 | name: bindingName, |
| 430 | text: value, |
| 431 | }; |
| 432 | } |
| 433 | if (Redacted.isRedacted(value)) { |
| 434 | const val = Redacted.value(value); |
| 435 | return { |
| 436 | type: "secret_text", |
| 437 | name: bindingName, |
| 438 | text: typeof val === "string" ? val : JSON.stringify(val), |
| 439 | }; |
| 440 | } |
| 441 | return { |
| 442 | type: "json", |
| 443 | name: bindingName, |
| 444 | json: value, |
| 445 | }; |
| 446 | }; |
| 447 | |
| 448 | /** |
| 449 | * Classify a binding into its wire shape. |
| 450 | * |
| 451 | * An Output that no native classifier recognized resolves to a plain env |
| 452 | * value, unknown until the engine resolves it, so its classification |
| 453 | * (plain_text vs secret_text vs json) is deferred to resolution time via |
| 454 | * {@link toValueBinding} — classifying eagerly would fall through to the |
| 455 | * `json` fallback and deploy e.g. a resolved `Redacted` secret as an |
| 456 | * unencrypted json binding. Resource refs never land in the Output arm: |
| 457 | * they surface their target's `Type` statically, so a native classifier |
| 458 | * (kv_namespace, r2_bucket, ...) already matched above. Whole-resource |
| 459 | * Outputs also land here (lazily, nothing is resolved); the caller rejects |
| 460 | * them before the returned Output is ever bound. |
| 461 | */ |
| 462 | const toBinding = ( |
| 463 | bindingName: string, |
| 464 | binding: WorkerBindingResource, |
| 465 | ): BindingSpec | Output.Output<WorkerBinding, unknown> => { |
| 466 | if (typeof binding === "string" || Redacted.isRedacted(binding)) { |
| 467 | return toValueBinding(bindingName, binding); |
| 468 | } else if (isAssets(binding)) { |
| 469 | return { |
| 470 | type: "assets", |
| 471 | name: bindingName, |
| 472 | }; |
| 473 | } else if (isNamespace(binding)) { |
| 474 | return { |
| 475 | type: "artifacts", |
| 476 | name: bindingName, |
| 477 | namespace: binding.namespace, |
| 478 | }; |
| 479 | } else if (isImages(binding)) { |
| 480 | return { |
| 481 | type: "images", |
| 482 | name: bindingName, |
| 483 | }; |
| 484 | } else if (isBrowser(binding)) { |
| 485 | return { |
| 486 | type: "browser", |
| 487 | name: bindingName, |
| 488 | }; |
| 489 | } else if (isStream(binding)) { |
| 490 | return { |
| 491 | type: "stream", |
| 492 | name: bindingName, |
| 493 | }; |
| 494 | } else if (isApp(binding)) { |
| 495 | return { |
| 496 | type: "flagship", |
| 497 | name: bindingName, |
| 498 | appId: binding.appId, |
| 499 | }; |
| 500 | } else if (isDataset(binding)) { |
| 501 | return { |
| 502 | type: "analytics_engine", |
| 503 | name: bindingName, |
| 504 | dataset: binding.dataset, |
| 505 | }; |
| 506 | } else if (isRateLimit(binding)) { |
| 507 | return { |
| 508 | type: "ratelimit", |
| 509 | name: bindingName, |
| 510 | namespaceId: binding.namespaceId, |
| 511 | simple: binding.simple, |
| 512 | }; |
| 513 | } else if (isSecretKey(binding)) { |
| 514 | return { |
| 515 | type: "secret_key", |
| 516 | name: bindingName, |
| 517 | format: binding.format, |
| 518 | algorithm: binding.algorithm, |
| 519 | usages: binding.usages, |
| 520 | keyBase64: binding.keyBase64, |
| 521 | keyJwk: binding.keyJwk, |
| 522 | }; |
| 523 | } else if (isSendEmail(binding)) { |
| 524 | return { |
| 525 | type: "send_email", |
| 526 | name: bindingName, |
| 527 | destinationAddress: binding.destinationAddress, |
| 528 | allowedDestinationAddresses: binding.allowedDestinationAddresses, |
| 529 | allowedSenderAddresses: binding.allowedSenderAddresses, |
| 530 | }; |
| 531 | } else if (isDurableObjectLike(binding)) { |
| 532 | return { |
| 533 | type: "durable_object_namespace", |
| 534 | name: bindingName, |
| 535 | className: binding.className ?? binding.name, |
| 536 | scriptName: binding.scriptName, |
| 537 | transferredFrom: normalizeTransferredFrom(binding.transferredFrom), |
| 538 | }; |
| 539 | } else if (isWorkflowLike(binding)) { |
| 540 | return { |
| 541 | type: "workflow", |
| 542 | name: bindingName, |
| 543 | workflowName: binding.workflowName ?? binding.name, |
| 544 | className: binding.className ?? binding.name, |
| 545 | scriptName: binding.scriptName, |
| 546 | }; |
| 547 | } else if (isVpcService(binding)) { |
| 548 | return { |
| 549 | type: "vpc_service", |
| 550 | name: bindingName, |
| 551 | serviceId: binding.serviceId, |
| 552 | }; |
| 553 | } else if (isDatabase(binding)) { |
| 554 | return { |
| 555 | type: "d1", |
| 556 | databaseId: binding.databaseId, |
| 557 | name: bindingName, |
| 558 | }; |
| 559 | } else if (isBucket(binding)) { |
| 560 | return { |
| 561 | type: "r2_bucket", |
| 562 | name: bindingName, |
| 563 | bucketName: binding.bucketName, |
| 564 | jurisdiction: binding.jurisdiction.pipe( |
| 565 | Output.map((jurisdiction) => |
| 566 | jurisdiction === "default" ? undefined : jurisdiction, |
| 567 | ), |
| 568 | ), |
| 569 | }; |
| 570 | } else if (isKVNamespace(binding)) { |
| 571 | return { |
| 572 | type: "kv_namespace", |
| 573 | name: bindingName, |
| 574 | namespaceId: binding.namespaceId, |
| 575 | }; |
| 576 | } else if (isQueue(binding)) { |
| 577 | return { |
| 578 | type: "queue", |
| 579 | name: bindingName, |
| 580 | queueName: binding.queueName, |
| 581 | // Alchemy-only mode discriminator for dev (stripped before upload). |
| 582 | queueId: binding.queueId, |
| 583 | }; |
| 584 | } else if (isDispatchNamespace(binding)) { |
| 585 | return { |
| 586 | type: "dispatch_namespace", |
| 587 | name: bindingName, |
| 588 | namespace: binding.name, |
| 589 | }; |
| 590 | } else if (isAiGateway(binding)) { |
| 591 | return { |
| 592 | type: "ai", |
| 593 | name: bindingName, |
| 594 | }; |
| 595 | } else if (isAI(binding)) { |
| 596 | return { |
| 597 | type: "ai", |
| 598 | name: bindingName, |
| 599 | }; |
| 600 | } else if (isSearchInstance(binding)) { |
| 601 | // Single-instance binding: `env.NAME` is the instance itself. The |
| 602 | // `namespace` qualifies which namespace the instance lives in (the |
| 603 | // account-provided `default` when unspecified). |
| 604 | return { |
| 605 | type: "ai_search", |
| 606 | name: bindingName, |
| 607 | instanceName: binding.instanceId, |
| 608 | namespace: binding.namespace, |
| 609 | }; |
| 610 | } else if (isSearchNamespace(binding)) { |
| 611 | // Namespace binding: `env.NAME.get(instanceName)` selects an instance |
| 612 | // within the namespace at runtime. |
| 613 | return { |
| 614 | type: "ai_search_namespace", |
| 615 | name: bindingName, |
| 616 | namespace: binding.name, |
| 617 | }; |
| 618 | } else if (isHyperdriveConnection(binding)) { |
| 619 | return { |
| 620 | type: "hyperdrive", |
| 621 | name: bindingName, |
| 622 | id: binding.hyperdriveId, |
| 623 | }; |
| 624 | } else if (isWorkerEntrypoint(binding)) { |
| 625 | // A named-entrypoint service binding (`Cloudflare.WorkerEntrypoint`). |
| 626 | // Tested BEFORE `isWorker` — the marker carries the Worker rather than |
| 627 | // being one, but keep the specific classifier ahead of the general one. |
| 628 | return { |
| 629 | type: "service", |
| 630 | name: bindingName, |
| 631 | service: binding.worker.workerName, |
| 632 | entrypoint: binding.entrypoint, |
| 633 | props: binding.props, |
| 634 | }; |
| 635 | } else if (isWorker(binding)) { |
| 636 | return { |
| 637 | type: "service", |
| 638 | name: bindingName, |
| 639 | service: binding.workerName, |
| 640 | }; |
| 641 | } else if (isIndex(binding)) { |
| 642 | return { |
| 643 | type: "vectorize", |
| 644 | name: bindingName, |
| 645 | indexName: binding.indexName, |
| 646 | }; |
| 647 | } else if (isSecret(binding)) { |
| 648 | return { |
| 649 | type: "secrets_store_secret", |
| 650 | name: bindingName, |
| 651 | secretName: binding.secretName, |
| 652 | storeId: binding.storeId, |
| 653 | }; |
| 654 | } else if (isVersionMetadata(binding)) { |
| 655 | return { |
| 656 | type: "version_metadata", |
| 657 | name: bindingName, |
| 658 | }; |
| 659 | } else if (isSelfUrl(binding)) { |
| 660 | // The Worker's own URL. The provider lowers this sentinel into a |
| 661 | // `plain_text` binding holding the resolved URL just before upload. |
| 662 | return { |
| 663 | type: "self_url", |
| 664 | name: bindingName, |
| 665 | }; |
| 666 | } else if (isSelf(binding)) { |
| 667 | // A service binding to the Worker itself. The provider lowers this |
| 668 | // sentinel into a `service` binding targeting the Worker's own |
| 669 | // physical name just before upload. |
| 670 | return { |
| 671 | type: "self_service", |
| 672 | name: bindingName, |
| 673 | }; |
| 674 | } else if (isWorkerLoader(binding)) { |
| 675 | return { |
| 676 | type: "worker_loader", |
| 677 | name: bindingName, |
| 678 | }; |
| 679 | } else if (isPipelinesStream(binding)) { |
| 680 | return { |
| 681 | type: "pipelines", |
| 682 | name: bindingName, |
| 683 | pipeline: binding.streamId, |
| 684 | }; |
| 685 | } else if (isLegacyPipeline(binding)) { |
| 686 | return { |
| 687 | type: "pipelines", |
| 688 | name: bindingName, |
| 689 | pipeline: binding.name, |
| 690 | }; |
| 691 | } else if (Output.isOutput(binding)) { |
| 692 | return Output.map( |
| 693 | binding, |
| 694 | (value: Json | Redacted.Redacted<Json> | VpcServiceLookup) => |
| 695 | // A `VpcService.lookup(...)` data source resolves to the service's |
| 696 | // attributes branded with the resource `Type`; classify it like the |
| 697 | // managed resource instead of a plain json env value. |
| 698 | isVpcService(value) |
| 699 | ? { |
| 700 | type: "vpc_service" as const, |
| 701 | name: bindingName, |
| 702 | serviceId: (value as VpcServiceLookup).serviceId, |
| 703 | } |
| 704 | : toValueBinding( |
| 705 | bindingName, |
| 706 | value as Json | Redacted.Redacted<Json>, |
| 707 | ), |
| 708 | ); |
| 709 | } else { |
| 710 | return { |
| 711 | type: "json", |
| 712 | name: bindingName, |
| 713 | json: binding, |
| 714 | }; |
| 715 | } |
| 716 | }; |
| 717 | |
| 718 | export const getCronBindings = ( |
| 719 | bindings: ReadonlyArray<ResourceBinding<Worker["Binding"]>>, |
| 720 | ) => Array.from(new Set(bindings.flatMap((b) => b.data.crons ?? []))); |
| 721 | |
| 722 | /** |
| 723 | * Merge the Workers Cache settings contributed by `yield* Cloudflare.cache()` |
| 724 | * bindings. Commutative: the cache is enabled (and cross-version) if any |
| 725 | * contributor asked for it. |
| 726 | */ |
| 727 | export const getCacheBinding = ( |
| 728 | bindings: ReadonlyArray<ResourceBinding<Worker["Binding"]>>, |
| 729 | ) => { |
| 730 | const configs = bindings.flatMap((b) => (b.data.cache ? [b.data.cache] : [])); |
| 731 | if (configs.length === 0) { |
| 732 | return undefined; |
| 733 | } |
| 734 | return { |
| 735 | enabled: configs.some((c) => c.enabled), |
| 736 | crossVersionCache: configs.some((c) => c.crossVersionCache) || undefined, |
| 737 | }; |
| 738 | }; |
| 739 | |
| 740 | const DEFAULT_OBSERVABILITY: WorkerObservability = { |
| 741 | enabled: true, |
| 742 | logs: { |
| 743 | enabled: true, |
| 744 | invocationLogs: true, |
| 745 | }, |
| 746 | }; |
| 747 | |
| 748 | /** |
| 749 | * Resolve the observability metadata to upload. An explicit |
| 750 | * `news.observability.traces` wins; otherwise the traces contributed by |
| 751 | * `Cloudflare.Telemetry()` (a single binding row — rows are collapsed by |
| 752 | * sid) fill in without clobbering the logs config. A cache-style `??` on |
| 753 | * the whole object would drop the default `logs.invocationLogs`. |
| 754 | */ |
| 755 | export const resolveObservability = ( |
| 756 | news: Pick<WorkerProps, "observability">, |
| 757 | bindings: ReadonlyArray<ResourceBinding<Worker["Binding"]>>, |
| 758 | ): WorkerObservability => { |
| 759 | const observability = news.observability ?? DEFAULT_OBSERVABILITY; |
| 760 | const bound = bindings.find((b) => b.data.observability?.traces != null)?.data |
| 761 | .observability?.traces; |
| 762 | return observability.traces != null || bound == null |
| 763 | ? observability |
| 764 | : { ...observability, traces: bound }; |
| 765 | }; |