| 1 | import * as logs from "@distilled.cloud/aws/cloudwatch-logs"; |
| 2 | import type { Credentials } from "@distilled.cloud/aws/Credentials"; |
| 3 | import * as iam from "@distilled.cloud/aws/iam"; |
| 4 | import type { CreateFunctionRequest } from "@distilled.cloud/aws/lambda"; |
| 5 | import * as Lambda from "@distilled.cloud/aws/lambda"; |
| 6 | import { Region } from "@distilled.cloud/aws/Region"; |
| 7 | import type * as lambda from "aws-lambda"; |
| 8 | import * as Cause from "effect/Cause"; |
| 9 | import * as Context from "effect/Context"; |
| 10 | import * as Data from "effect/Data"; |
| 11 | import * as Duration from "effect/Duration"; |
| 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as Exit from "effect/Exit"; |
| 14 | import * as Layer from "effect/Layer"; |
| 15 | import * as Option from "effect/Option"; |
| 16 | import * as Schedule from "effect/Schedule"; |
| 17 | import * as Scope from "effect/Scope"; |
| 18 | import * as Stream from "effect/Stream"; |
| 19 | import type { HttpClient } from "effect/http/HttpClient"; |
| 20 | import type * as rolldown from "rolldown"; |
| 21 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 22 | import type * as Bundle from "../../Bundle/Bundle.ts"; |
| 23 | import type { PackageInstall } from "../../Bundle/InstalledPackages.ts"; |
| 24 | import { deepEqual, havePropsChanged, isResolved } from "../../Diff.ts"; |
| 25 | import { isScopeEjected, type HttpEffect } from "../../Http.ts"; |
| 26 | import * as Output from "../../Output.ts"; |
| 27 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 28 | import { Platform, type Main, type PlatformProps } from "../../Platform.ts"; |
| 29 | import type { LogLine, LogsInput } from "../../Provider.ts"; |
| 30 | import * as Provider from "../../Provider.ts"; |
| 31 | import { Resource, type ResourceBinding } from "../../Resource.ts"; |
| 32 | import { packEnvValue, unpackEnvValue } from "../../RuntimeContext.ts"; |
| 33 | import * as Serverless from "../../Serverless/index.ts"; |
| 34 | import { buildEventTelemetry } from "../../Telemetry.ts"; |
| 35 | import { Stack } from "../../Stack.ts"; |
| 36 | import { Stage } from "../../Stage.ts"; |
| 37 | import { |
| 38 | createInternalTags, |
| 39 | createTagsList, |
| 40 | hasAlchemyTags, |
| 41 | hasTags, |
| 42 | } from "../../Tags.ts"; |
| 43 | import { sha256 } from "../../Util/sha256.ts"; |
| 44 | import { zipCode } from "../../Util/zip.ts"; |
| 45 | import { Assets } from "../Assets.ts"; |
| 46 | import { AWSEnvironment } from "../Environment.ts"; |
| 47 | import * as IAM from "../IAM/index.ts"; |
| 48 | import type { PolicyStatement } from "../IAM/Policy.ts"; |
| 49 | import type { Providers } from "../Providers.ts"; |
| 50 | import { |
| 51 | syncEventInvokeConfig, |
| 52 | type EventInvokeConfig, |
| 53 | } from "./EventInvokeConfig.ts"; |
| 54 | import { makeFunctionBundler } from "./FunctionBundle.ts"; |
| 55 | import { |
| 56 | decodeFunctionImageSource, |
| 57 | type FunctionImageAttributes, |
| 58 | type FunctionImageIdentity, |
| 59 | type FunctionImageSource, |
| 60 | makeFunctionImage, |
| 61 | } from "./FunctionImage.ts"; |
| 62 | import { makeFunctionHttpHandler } from "./HttpServer.ts"; |
| 63 | |
| 64 | export type { FunctionImageSource } from "./FunctionImage.ts"; |
| 65 | |
| 66 | export const FunctionTypeId = "AWS.Lambda.Function" as const; |
| 67 | export type FunctionTypeId = typeof FunctionTypeId; |
| 68 | |
| 69 | class FunctionUpdatePending extends Data.TaggedError("FunctionUpdatePending")<{ |
| 70 | functionName: string; |
| 71 | state?: string; |
| 72 | stateReason?: string; |
| 73 | lastUpdateStatus?: string; |
| 74 | lastUpdateStatusReason?: string; |
| 75 | }> { |
| 76 | override get message() { |
| 77 | return `Lambda function ${this.functionName} is not ready: state=${this.state ?? "unknown"} (${this.stateReason ?? "no reason"}), update=${this.lastUpdateStatus ?? "unknown"} (${this.lastUpdateStatusReason ?? "no reason"})`; |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | class FunctionUpdateFailed extends Data.TaggedError("FunctionUpdateFailed")<{ |
| 82 | functionName: string; |
| 83 | reason?: string; |
| 84 | }> { |
| 85 | override get message() { |
| 86 | return `Lambda function ${this.functionName} update failed: ${this.reason ?? "unknown reason"}`; |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | export class HandlerContext extends Context.Service< |
| 91 | HandlerContext, |
| 92 | lambda.Context |
| 93 | >()("AWS.Lambda.HandlerContext") {} |
| 94 | |
| 95 | export const isFunction = (value: any): value is Function => { |
| 96 | return ( |
| 97 | typeof value === "object" && |
| 98 | value !== null && |
| 99 | "Type" in value && |
| 100 | value.Type === "AWS.Lambda.Function" |
| 101 | ); |
| 102 | }; |
| 103 | |
| 104 | /** |
| 105 | * True for any Alchemy host that accepts the `{ env, policyStatements }` |
| 106 | * binding contract: the Lambda `Function`, the ECS `Task` and `Service`, and |
| 107 | * the EKS `ServerHost`. AWS `Binding.Service` implementations guard their |
| 108 | * deploy-time |
| 109 | * `host.bind` registration with this predicate so every existing capability |
| 110 | * (S3, DynamoDB, SQS, …) lands its IAM on whichever of the three hosts is in |
| 111 | * context — the Lambda execution role, the ECS task role, or the EKS |
| 112 | * pod-identity role. |
| 113 | * |
| 114 | * The type guard narrows to `Function` deliberately: all three hosts expose an |
| 115 | * identical `{ env, policyStatements }` bind contract and only `host.bind` / |
| 116 | * `host.LogicalId` are ever touched inside the guarded block, so downstream |
| 117 | * typing is unchanged while the runtime check widens to all three. |
| 118 | */ |
| 119 | export const isBindingHost = (value: any): value is Function => { |
| 120 | return ( |
| 121 | typeof value === "object" && |
| 122 | value !== null && |
| 123 | "Type" in value && |
| 124 | (value.Type === "AWS.Lambda.Function" || |
| 125 | value.Type === "AWS.ECS.Task" || |
| 126 | value.Type === "AWS.ECS.Service" || |
| 127 | value.Type === "Kubernetes.Deployment" || |
| 128 | value.Type === "Kubernetes.Job") |
| 129 | ); |
| 130 | }; |
| 131 | |
| 132 | export interface FunctionBuildOptions |
| 133 | extends Partial<rolldown.InputOptions>, Bundle.BundleExtraOptions { |
| 134 | /** |
| 135 | * Native or Node-only packages to install into the Lambda artifact with npm, |
| 136 | * targeting Linux and the function's architecture. |
| 137 | * |
| 138 | * @example |
| 139 | * ```typescript |
| 140 | * build: { install: ["sharp"] } |
| 141 | * ``` |
| 142 | * |
| 143 | * @example |
| 144 | * ```typescript |
| 145 | * build: { install: { sharp: "^0.33.5" } } |
| 146 | * ``` |
| 147 | */ |
| 148 | readonly install?: PackageInstall; |
| 149 | readonly output?: Partial<rolldown.OutputOptions>; |
| 150 | } |
| 151 | |
| 152 | export type FunctionArchitecture = "x86_64" | "arm64"; |
| 153 | |
| 154 | /** |
| 155 | * Overrides for instructions declared by a Lambda container image. Set |
| 156 | * directly on {@link FunctionImageProps.image} alongside the image source; |
| 157 | * omit to use the image's own `CMD` / `ENTRYPOINT` / `WORKDIR`. |
| 158 | */ |
| 159 | export interface FunctionImageConfig { |
| 160 | /** Parameters passed to the image entry point, overriding Dockerfile `CMD`. */ |
| 161 | command?: readonly string[]; |
| 162 | /** Runtime executable, overriding Dockerfile `ENTRYPOINT`. */ |
| 163 | entryPoint?: readonly string[]; |
| 164 | /** Working directory, overriding Dockerfile `WORKDIR`. */ |
| 165 | workingDirectory?: string; |
| 166 | } |
| 167 | |
| 168 | /** |
| 169 | * Reference to an EFS access point: a raw access point ARN or anything |
| 170 | * exposing an `accessPointArn` attribute (e.g. an `AWS.EFS.AccessPoint` |
| 171 | * resource). |
| 172 | */ |
| 173 | export type AccessPointRef = string | { accessPointArn: string }; |
| 174 | |
| 175 | /** |
| 176 | * Reference to a Lambda layer version: a raw layer version ARN or anything |
| 177 | * exposing a `layerVersionArn` attribute (e.g. an `AWS.Lambda.LayerVersion` |
| 178 | * resource). |
| 179 | */ |
| 180 | export type LayerRef = string | { layerVersionArn: string }; |
| 181 | |
| 182 | /** Resolve a {@link LayerRef} to its layer version ARN. */ |
| 183 | export const layerVersionArnOf = (layer: LayerRef): string => |
| 184 | typeof layer === "string" ? layer : layer.layerVersionArn; |
| 185 | |
| 186 | /** |
| 187 | * Resolve an {@link AccessPointRef} (or the legacy raw-`arn` field) on a |
| 188 | * `fileSystemConfigs` entry to the access point ARN. |
| 189 | */ |
| 190 | const accessPointArnOf = (config: { |
| 191 | accessPoint?: AccessPointRef; |
| 192 | arn?: string; |
| 193 | }): string | undefined => |
| 194 | typeof config.accessPoint === "string" |
| 195 | ? config.accessPoint |
| 196 | : typeof (config.accessPoint as { accessPointArn?: unknown } | undefined) |
| 197 | ?.accessPointArn === "string" |
| 198 | ? (config.accessPoint as { accessPointArn: string }).accessPointArn |
| 199 | : config.arn; |
| 200 | |
| 201 | export interface FunctionUrlConfig { |
| 202 | /** |
| 203 | * Authentication type for the Lambda function URL. |
| 204 | * `NONE` creates a public endpoint. `AWS_IAM` requires SigV4-signed callers. |
| 205 | * @default "NONE" |
| 206 | */ |
| 207 | authType?: Lambda.FunctionUrlAuthType; |
| 208 | /** |
| 209 | * Cross-origin resource sharing configuration for the function URL. |
| 210 | */ |
| 211 | cors?: Lambda.Cors; |
| 212 | /** |
| 213 | * Invocation mode for the function URL. |
| 214 | * @default "BUFFERED" |
| 215 | */ |
| 216 | invokeMode?: Lambda.InvokeMode; |
| 217 | } |
| 218 | |
| 219 | export interface FunctionCommonProps extends PlatformProps { |
| 220 | /** |
| 221 | * Whether to create a Lambda function URL, or its configuration. |
| 222 | * `functionUrl: true` creates a public Function URL with `authType: "NONE"`. |
| 223 | * Set `functionUrl: false` to disable the Function URL. |
| 224 | * @default true |
| 225 | */ |
| 226 | functionUrl?: boolean | FunctionUrlConfig; |
| 227 | functionName?: string; |
| 228 | /** |
| 229 | * Instruction set architecture for the Lambda function. |
| 230 | */ |
| 231 | architecture?: FunctionArchitecture; |
| 232 | memorySize?: number; |
| 233 | env?: Record<string, any>; |
| 234 | /** |
| 235 | * Attach the function to a VPC for private AWS connectivity such as Aurora. |
| 236 | */ |
| 237 | vpc?: { |
| 238 | subnetIds: string[]; |
| 239 | securityGroupIds: string[]; |
| 240 | }; |
| 241 | /** |
| 242 | * EFS file systems to mount into the function's execution environment. |
| 243 | * Each entry mounts an EFS access point — pass the `AWS.EFS.AccessPoint` |
| 244 | * resource itself (or its ARN) — at a local path that must begin with |
| 245 | * `/mnt/`. Requires `vpc`: the function must be attached to a VPC that can |
| 246 | * reach an available EFS mount target for the file system. The execution |
| 247 | * role is automatically granted EFS client access |
| 248 | * (`AmazonElasticFileSystemClientReadWriteAccess`). |
| 249 | * |
| 250 | * Prefer the host-agnostic `AWS.EFS.Mount` binding |
| 251 | * (`yield* AWS.EFS.mount(accessPoint, { path: "/mnt/data" })` inside the |
| 252 | * function body with the `AWS.EFS.MountLive` layer) — it wires the same |
| 253 | * config plus least-privilege IAM through the binding channel and also |
| 254 | * works on ECS. |
| 255 | */ |
| 256 | fileSystemConfigs?: { |
| 257 | /** |
| 258 | * The EFS access point to mount — an `AWS.EFS.AccessPoint` resource or |
| 259 | * its ARN. Exactly one of `accessPoint` or `arn` is required. |
| 260 | */ |
| 261 | accessPoint?: AccessPointRef; |
| 262 | /** |
| 263 | * ARN of the EFS access point to mount |
| 264 | * (e.g. `accessPoint.accessPointArn`). Alias of {@link accessPoint} for |
| 265 | * raw-string configs. |
| 266 | */ |
| 267 | arn?: string; |
| 268 | /** |
| 269 | * Local mount path inside the function. Must begin with `/mnt/` |
| 270 | * (e.g. `/mnt/files`). |
| 271 | */ |
| 272 | localMountPath: string; |
| 273 | }[]; |
| 274 | /** |
| 275 | * Maximum execution time before the function is forcibly terminated. |
| 276 | * Rounded up to whole seconds. |
| 277 | * |
| 278 | * @default 3 seconds (AWS Lambda default) |
| 279 | */ |
| 280 | timeout?: Duration.Duration; |
| 281 | /** |
| 282 | * Maximum number of concurrent executions reserved for this function. |
| 283 | * Omit to remove the function-level reserved concurrency limit. |
| 284 | */ |
| 285 | reservedConcurrentExecutions?: number; |
| 286 | /** |
| 287 | * AWS X-Ray tracing mode for the function. |
| 288 | * |
| 289 | * `"Active"` samples and records incoming requests as X-Ray traces and |
| 290 | * attaches the `AWSXRayDaemonWriteAccess` managed policy to the execution |
| 291 | * role so the runtime can publish trace segments. `"PassThrough"` only |
| 292 | * forwards an upstream trace header without sampling. |
| 293 | * |
| 294 | * @default "PassThrough" |
| 295 | */ |
| 296 | tracing?: "Active" | "PassThrough"; |
| 297 | /** |
| 298 | * Asynchronous invocation settings (retries, event age, destinations) for |
| 299 | * the unqualified function. Omit to remove any existing config and fall |
| 300 | * back to Lambda's defaults (2 retries, 6-hour max event age, no |
| 301 | * destinations). Use {@link AliasProps.eventInvokeConfig} to scope the |
| 302 | * config to an alias instead. |
| 303 | */ |
| 304 | eventInvokeConfig?: EventInvokeConfig; |
| 305 | } |
| 306 | |
| 307 | export interface FunctionZipProps extends FunctionCommonProps { |
| 308 | /** |
| 309 | * Entry module for the bundled Lambda function. |
| 310 | */ |
| 311 | main: string; |
| 312 | /** |
| 313 | * Set to `false` to skip bundling and deploy `main`'s directory as-is: |
| 314 | * every file in the directory containing `main` ships in the code |
| 315 | * archive, preserving relative paths. Use for framework outputs that are |
| 316 | * already self-contained deployment units (e.g. nitro's |
| 317 | * `.output/server`, OpenNext's server functions) where re-bundling can |
| 318 | * break `require`s of packaged `node_modules`. Implies external mode: |
| 319 | * `handler` names an export of `main`, and the Lambda handler string is |
| 320 | * derived from `main`'s basename (e.g. `index.mjs` → `index.handler`). |
| 321 | * @default true |
| 322 | */ |
| 323 | bundle?: false; |
| 324 | image?: never; |
| 325 | /** |
| 326 | * Exported handler symbol inside the bundled module. |
| 327 | * @default "handler" |
| 328 | */ |
| 329 | handler?: string; |
| 330 | // TODO(sam): use a Layer instead so we can manage Effect platform? |
| 331 | runtime?: "nodejs22.x" | "nodejs24.x"; |
| 332 | /** |
| 333 | * Lambda layers to attach — pass an `AWS.Lambda.LayerVersion` resource |
| 334 | * directly, or a raw layer version ARN (e.g. an AWS-managed layer). Layers |
| 335 | * are extracted into `/opt` in the order given. Omit or pass `[]` to detach |
| 336 | * every layer. |
| 337 | */ |
| 338 | layers?: LayerRef[]; |
| 339 | /** |
| 340 | * Bundler configuration for {@link main}: rolldown input options, `output` |
| 341 | * overrides, `install` for native packages, `pure`, and the bundle analyzer. |
| 342 | * Unused code is tree-shaken. `effect`, alchemy, and `@distilled.cloud` |
| 343 | * are marked pure so unused parts prune more aggressively. List extra |
| 344 | * packages with `pure.packages`, or disable with `pure: false`. |
| 345 | */ |
| 346 | build?: FunctionBuildOptions; |
| 347 | uploadSourceMap?: boolean; |
| 348 | exports?: string[]; |
| 349 | /** |
| 350 | * Wire-level `DurableConfig` applied at `CreateFunction`. |
| 351 | * |
| 352 | * @internal Set exclusively by the `AWS.Lambda.DurableFunction` wrapper — |
| 353 | * never set this directly. Durability is a **create-time** property of a |
| 354 | * Lambda function, so a presence change replaces the function (see `diff`). |
| 355 | * The base Function is otherwise durability-agnostic; author durable |
| 356 | * orchestrators with `AWS.Lambda.DurableFunction`. |
| 357 | */ |
| 358 | durableConfig?: Lambda.DurableConfig; |
| 359 | } |
| 360 | |
| 361 | export interface FunctionImageProps extends FunctionCommonProps { |
| 362 | main?: never; |
| 363 | /** |
| 364 | * Instruction set architecture for the Lambda function and its image build. |
| 365 | * `x86_64` builds `linux/amd64`; `arm64` builds `linux/arm64`. |
| 366 | */ |
| 367 | architecture: FunctionArchitecture; |
| 368 | /** |
| 369 | * Deploy an existing private ECR image, or build a Lambda-compatible image |
| 370 | * from a local Docker context. Local builds use a managed private ECR |
| 371 | * repository with content-addressed tags. |
| 372 | */ |
| 373 | image: FunctionImageSource; |
| 374 | handler?: never; |
| 375 | runtime?: never; |
| 376 | layers?: never; |
| 377 | build?: never; |
| 378 | uploadSourceMap?: never; |
| 379 | exports?: never; |
| 380 | // Durability needs a `main` for the orchestrator body — see |
| 381 | // DurableFunctionProps. |
| 382 | durableConfig?: never; |
| 383 | } |
| 384 | |
| 385 | export type FunctionProps = FunctionZipProps | FunctionImageProps; |
| 386 | |
| 387 | const isFunctionImageProps = ( |
| 388 | props: FunctionProps, |
| 389 | ): props is FunctionImageProps => props.image !== undefined; |
| 390 | |
| 391 | interface FunctionPackageValidationProps { |
| 392 | image?: unknown; |
| 393 | main?: unknown; |
| 394 | handler?: unknown; |
| 395 | runtime?: unknown; |
| 396 | layers?: unknown; |
| 397 | build?: unknown; |
| 398 | uploadSourceMap?: unknown; |
| 399 | exports?: unknown; |
| 400 | durableConfig?: unknown; |
| 401 | } |
| 402 | |
| 403 | /** |
| 404 | * Validate package-mode exclusivity before any Lambda or ECR mutation. |
| 405 | * @internal |
| 406 | */ |
| 407 | export const validateFunctionPackageProps = Effect.fn( |
| 408 | "AWS.Lambda.validateFunctionPackageProps", |
| 409 | )(function* (id: string, props: FunctionPackageValidationProps) { |
| 410 | if (props.image === undefined) { |
| 411 | if (props.main === undefined) { |
| 412 | return yield* Effect.fail( |
| 413 | new Error(`Function(${id}): declare exactly one of main or image`), |
| 414 | ); |
| 415 | } |
| 416 | return; |
| 417 | } |
| 418 | |
| 419 | const incompatible = [ |
| 420 | ["main", props.main], |
| 421 | ["handler", props.handler], |
| 422 | ["runtime", props.runtime], |
| 423 | ["layers", props.layers], |
| 424 | ["build", props.build], |
| 425 | ["uploadSourceMap", props.uploadSourceMap], |
| 426 | ["exports", props.exports], |
| 427 | ["durableConfig", props.durableConfig], |
| 428 | ].flatMap(([name, value]) => (value === undefined ? [] : [name])); |
| 429 | if (incompatible.length > 0) { |
| 430 | return yield* Effect.fail( |
| 431 | new Error( |
| 432 | `Function(${id}): image functions cannot use ZIP/runtime options: ${incompatible.join(", ")}`, |
| 433 | ), |
| 434 | ); |
| 435 | } |
| 436 | }); |
| 437 | |
| 438 | /** |
| 439 | * The Lambda `ImageConfig` for an image source's overrides; `undefined` when |
| 440 | * the source declares none, so the image's own instructions apply. |
| 441 | */ |
| 442 | const toLambdaImageConfig = ( |
| 443 | config: FunctionImageConfig | undefined, |
| 444 | ): Lambda.ImageConfig | undefined => |
| 445 | config === undefined || |
| 446 | (config.command === undefined && |
| 447 | config.entryPoint === undefined && |
| 448 | config.workingDirectory === undefined) |
| 449 | ? undefined |
| 450 | : { |
| 451 | Command: config.command === undefined ? undefined : [...config.command], |
| 452 | EntryPoint: |
| 453 | config.entryPoint === undefined ? undefined : [...config.entryPoint], |
| 454 | WorkingDirectory: config.workingDirectory, |
| 455 | }; |
| 456 | |
| 457 | /** |
| 458 | * `UpdateFunctionConfiguration` preserves old image overrides when the field |
| 459 | * is omitted. An empty object therefore means "clear overrides" on update. |
| 460 | */ |
| 461 | const imageConfigForUpdate = ( |
| 462 | props: FunctionProps, |
| 463 | ): Lambda.ImageConfig | undefined => |
| 464 | isFunctionImageProps(props) |
| 465 | ? (toLambdaImageConfig(props.image) ?? {}) |
| 466 | : undefined; |
| 467 | |
| 468 | /** |
| 469 | * The Lambda `Handler` string for a function's props: `<file>.<export>`. |
| 470 | * Bundled functions always emit `index.js`; prebuilt directories |
| 471 | * (`bundle: false`) keep `main`'s own basename. The export half honors |
| 472 | * `handler` only outside Effect mode (see the note at the call site). |
| 473 | */ |
| 474 | const handlerStringOf = (props: FunctionZipProps): string => { |
| 475 | const externalMode = props.isExternal || props.bundle === false; |
| 476 | // `main` may be an unresolved Output during precreate (the stub's mock |
| 477 | // code exports `index.*` anyway); the real Handler is applied at |
| 478 | // reconcile, where props are resolved. |
| 479 | const base = |
| 480 | props.bundle === false && typeof props.main === "string" |
| 481 | ? props.main |
| 482 | .slice( |
| 483 | Math.max( |
| 484 | props.main.lastIndexOf("/"), |
| 485 | props.main.lastIndexOf("\\"), |
| 486 | ) + 1, |
| 487 | ) |
| 488 | .replace(/\.[^.]+$/, "") |
| 489 | : "index"; |
| 490 | return `${base}.${externalMode ? (props.handler ?? "default") : "default"}`; |
| 491 | }; |
| 492 | |
| 493 | /** |
| 494 | * Normalize a {@link FunctionProps.timeout} to whole seconds. |
| 495 | * |
| 496 | * State JSON round-trips flatten a `Duration` to its `toJSON` shape |
| 497 | * (`{_id:"Duration",_tag:"Millis"|"Nanos"|"Infinity",...}`), which is not a |
| 498 | * valid `Duration.Input`. Reconstruct an input that `Duration.toSeconds` |
| 499 | * accepts before delegating. |
| 500 | */ |
| 501 | export const toTimeoutSeconds = ( |
| 502 | timeout: Duration.Duration | undefined, |
| 503 | ): number | undefined => { |
| 504 | if (timeout === undefined) return undefined; |
| 505 | const json = timeout as { |
| 506 | _id?: unknown; |
| 507 | _tag?: "Millis" | "Nanos" | "Infinity" | "NegativeInfinity"; |
| 508 | millis?: number; |
| 509 | nanos?: string; |
| 510 | }; |
| 511 | const input: Duration.Input = |
| 512 | json._id === "Duration" |
| 513 | ? json._tag === "Millis" |
| 514 | ? json.millis! |
| 515 | : json._tag === "Nanos" |
| 516 | ? BigInt(json.nanos!) |
| 517 | : "Infinity" |
| 518 | : timeout; |
| 519 | const seconds = Duration.toSeconds(input); |
| 520 | return Number.isFinite(seconds) ? Math.max(1, Math.ceil(seconds)) : undefined; |
| 521 | }; |
| 522 | |
| 523 | export interface Function extends Resource< |
| 524 | FunctionTypeId, |
| 525 | FunctionProps, |
| 526 | { |
| 527 | functionArn: string; |
| 528 | functionName: string; |
| 529 | functionUrl: string | undefined; |
| 530 | roleName: string; |
| 531 | roleArn: string; |
| 532 | code: { |
| 533 | hash: string; |
| 534 | image?: Omit<FunctionImageAttributes, "hash">; |
| 535 | }; |
| 536 | reservedConcurrentExecutions?: number; |
| 537 | }, |
| 538 | { |
| 539 | env?: Record<string, any>; |
| 540 | policyStatements?: PolicyStatement[]; |
| 541 | /** |
| 542 | * VPC attachment requested by a binding (e.g. `RDS.Connect` with |
| 543 | * `subnetIds`/`securityGroupIds`). Merged (set-union) with the |
| 544 | * Function's own `vpc` prop and any other bindings' requests — both |
| 545 | * paths converge on the same underlying Lambda VPC config. |
| 546 | */ |
| 547 | vpc?: { |
| 548 | subnetIds: string[]; |
| 549 | securityGroupIds: string[]; |
| 550 | }; |
| 551 | /** |
| 552 | * EFS mounts requested through the binding channel (e.g. `EFS.Mount`). |
| 553 | * Merged (deduped by `localMountPath`) with the Function's own |
| 554 | * `fileSystemConfigs` prop. |
| 555 | */ |
| 556 | fileSystemConfigs?: { |
| 557 | /** ARN of the EFS access point to mount. */ |
| 558 | arn: string; |
| 559 | /** Local mount path inside the function (must begin with `/mnt/`). */ |
| 560 | localMountPath: string; |
| 561 | }[]; |
| 562 | }, |
| 563 | Providers |
| 564 | > {} |
| 565 | |
| 566 | export type FunctionServices = Credentials | Region | AWSEnvironment; |
| 567 | |
| 568 | export type FunctionShape = Main<FunctionServices>; |
| 569 | |
| 570 | export interface NormalizedFunctionUrlConfig { |
| 571 | authType: Lambda.FunctionUrlAuthType; |
| 572 | cors?: Lambda.Cors; |
| 573 | invokeMode: Lambda.InvokeMode; |
| 574 | } |
| 575 | |
| 576 | export const normalizeFunctionUrl = ( |
| 577 | url: FunctionProps["functionUrl"] = true, |
| 578 | ): NormalizedFunctionUrlConfig | undefined => { |
| 579 | if (url === false) { |
| 580 | return undefined; |
| 581 | } |
| 582 | if (url === true || url === undefined) { |
| 583 | return { |
| 584 | authType: "NONE", |
| 585 | invokeMode: "BUFFERED", |
| 586 | }; |
| 587 | } |
| 588 | return { |
| 589 | authType: url.authType ?? "NONE", |
| 590 | cors: url.cors, |
| 591 | invokeMode: url.invokeMode ?? "BUFFERED", |
| 592 | }; |
| 593 | }; |
| 594 | |
| 595 | /** |
| 596 | * An AWS Lambda host resource that combines code bundling, IAM role |
| 597 | * provisioning, and runtime binding collection. |
| 598 | * |
| 599 | * `Function` is the canonical runtime host for AWS. It can either bundle a |
| 600 | * TypeScript entry module into a zip artifact or build a user-authored |
| 601 | * Dockerfile into a Lambda container image. In both modes Alchemy creates the |
| 602 | * execution role and applies bindings; image mode additionally owns the |
| 603 | * private ECR repository and Lambda pull policy. |
| 604 | * |
| 605 | * Zip-packaged functions can be defined in two ways: |
| 606 | * |
| 607 | * - **Async** — plain handler export, no Effect runtime in the bundle. |
| 608 | * - **Effect** — Effect implementation with typed bindings and event sources. |
| 609 | * |
| 610 | * See [Effect handlers vs async handlers](/infrastructure-as-effects/runtime#effect-handlers-vs-async-handlers) |
| 611 | * for plain handler patterns, or the |
| 612 | * [Lambda guide](/aws/compute/lambda) |
| 613 | * for the full Effect-based approach with bindings, event sources, and sinks. |
| 614 | * |
| 615 | * :::caution[Request finalizers block the response — there is no `waitUntil` on Lambda] |
| 616 | * `Effect.addFinalizer` in a handler runs **before the response is |
| 617 | * returned**: a buffered invocation's response is not released until the |
| 618 | * Invoke phase completes, and no deferral scheme is reliable (dangling |
| 619 | * promises are dropped on crash/timeout resets and their sockets rarely |
| 620 | * survive the freeze — silent data loss). Keep request finalizers cheap |
| 621 | * (closing a pool is milliseconds), and write anything that must not be |
| 622 | * lost durably — a queue, a table — inside the handler itself. Init-level |
| 623 | * finalizers instead run in the 500 ms `SIGTERM` window at sandbox |
| 624 | * shutdown, which the generated entry obtains by registering an internal |
| 625 | * extension. See |
| 626 | * [Sandbox scope vs invocation scope](/aws/compute/lambda#sandbox-scope-vs-invocation-scope). |
| 627 | * ::: |
| 628 | * ### Async Functions |
| 629 | * Point `main` at a file that exports a standard Lambda handler. No |
| 630 | * Effect runtime is included in the bundle. Useful when migrating |
| 631 | * existing Lambda functions or when you don't need Effect. |
| 632 | * |
| 633 | * **Example:** Defining an async Lambda in your stack |
| 634 | * ```typescript |
| 635 | * // alchemy.run.ts |
| 636 | * import * as AWS from "alchemy/AWS"; |
| 637 | * |
| 638 | * const func = yield* AWS.Lambda.Function("ApiFunction", { |
| 639 | * main: "./src/handler.ts", |
| 640 | * functionUrl: true, |
| 641 | * }); |
| 642 | * ``` |
| 643 | * |
| 644 | * **Example:** Function using ARM64 |
| 645 | * ```typescript |
| 646 | * const func = yield* AWS.Lambda.Function("ArmFunction", { |
| 647 | * main: "./src/handler.ts", |
| 648 | * architecture: "arm64", |
| 649 | * }); |
| 650 | * ``` |
| 651 | * |
| 652 | * **Example:** Function with a native package (Sharp) |
| 653 | * ```typescript |
| 654 | * const func = yield* AWS.Lambda.Function("ImageProcessor", { |
| 655 | * main: "./src/handler.ts", |
| 656 | * architecture: "arm64", |
| 657 | * build: { |
| 658 | * install: ["sharp"], |
| 659 | * }, |
| 660 | * }); |
| 661 | * ``` |
| 662 | * |
| 663 | * **Example:** Writing the async handler |
| 664 | * ```typescript |
| 665 | * // src/handler.ts |
| 666 | * export const handler = async (event: any) => { |
| 667 | * return { |
| 668 | * statusCode: 200, |
| 669 | * body: JSON.stringify({ message: "Hello from Lambda!" }), |
| 670 | * }; |
| 671 | * }; |
| 672 | * ``` |
| 673 | * |
| 674 | * ### Container Image Functions |
| 675 | * Set `image` instead of `main` to deploy an existing private ECR image or to |
| 676 | * build and publish a local Docker context. Image sources must be literal |
| 677 | * because Lambda's pre-create phase needs the deployable image before normal |
| 678 | * Output resolution. |
| 679 | * |
| 680 | * #### Existing ECR image with runtime overrides |
| 681 | * ```typescript |
| 682 | * const func = yield* AWS.Lambda.Function("Worker", { |
| 683 | * image: { |
| 684 | * uri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/worker@sha256:...", |
| 685 | * command: ["app.handler"], |
| 686 | * entryPoint: ["/lambda-entrypoint.sh"], |
| 687 | * workingDirectory: "/var/task", |
| 688 | * }, |
| 689 | * architecture: "x86_64", |
| 690 | * }); |
| 691 | * ``` |
| 692 | * |
| 693 | * Tagged URIs are resolved through ECR on each plan. If a tag is repointed to |
| 694 | * a new digest, Alchemy updates the Lambda function even though the URI string |
| 695 | * is unchanged. External repositories are never modified or deleted. |
| 696 | * |
| 697 | * #### Build a Lambda container image |
| 698 | * ```typescript |
| 699 | * const func = yield* AWS.Lambda.Function("JavaFunction", { |
| 700 | * image: { |
| 701 | * context: "./lambda", |
| 702 | * dockerfile: "Dockerfile", |
| 703 | * buildArgs: { |
| 704 | * APP_ENV: "production", |
| 705 | * }, |
| 706 | * }, |
| 707 | * architecture: "arm64", |
| 708 | * functionUrl: false, |
| 709 | * }); |
| 710 | * ``` |
| 711 | * |
| 712 | * The Dockerfile owns the runtime and handler. Alchemy does not generate a |
| 713 | * Node.js adapter or otherwise impose a language. For example, |
| 714 | * `./lambda/Dockerfile` can use AWS's Java base image: |
| 715 | * |
| 716 | * ```dockerfile |
| 717 | * FROM public.ecr.aws/lambda/java:21 |
| 718 | * COPY target/function.jar ${LAMBDA_TASK_ROOT}/lib/ |
| 719 | * CMD ["com.example.Handler::handleRequest"] |
| 720 | * ``` |
| 721 | * |
| 722 | * ### Effect Functions |
| 723 | * Pass the Effect implementation as the third argument. Bindings |
| 724 | * attach IAM permissions and environment variables at deploy time, |
| 725 | * while the runtime execution context collects listeners and exports. |
| 726 | * |
| 727 | * **Example:** Effect Function with HTTP handler |
| 728 | * ```typescript |
| 729 | * export default class ApiFunction extends AWS.Lambda.Function<ApiFunction>()( |
| 730 | * "ApiFunction", |
| 731 | * { main: import.meta.url, functionUrl: true }, |
| 732 | * Effect.gen(function* () { |
| 733 | * // init: bind resources |
| 734 | * const getItem = yield* AWS.DynamoDB.GetItem(table); |
| 735 | * |
| 736 | * return { |
| 737 | * // runtime: use them |
| 738 | * fetch: Effect.gen(function* () { |
| 739 | * const request = yield* HttpServerRequest; |
| 740 | * const url = new URL(request.url); |
| 741 | * const id = url.searchParams.get("id"); |
| 742 | * const result = yield* getItem({ Key: { pk: { S: id! } } }); |
| 743 | * return yield* HttpServerResponse.json(result.Item); |
| 744 | * }), |
| 745 | * }; |
| 746 | * }), |
| 747 | * ) {} |
| 748 | * ``` |
| 749 | * |
| 750 | * ### Configuration |
| 751 | * **Example:** Function with URL |
| 752 | * ```typescript |
| 753 | * const func = yield* AWS.Lambda.Function("ApiFunction", { |
| 754 | * main: "./src/handler.ts", |
| 755 | * functionUrl: true, |
| 756 | * }); |
| 757 | * ``` |
| 758 | * |
| 759 | * **Example:** Function URL with IAM auth |
| 760 | * ```typescript |
| 761 | * const func = yield* AWS.Lambda.Function("ApiFunction", { |
| 762 | * main: "./src/handler.ts", |
| 763 | * functionUrl: { |
| 764 | * authType: "AWS_IAM", |
| 765 | * }, |
| 766 | * }); |
| 767 | * ``` |
| 768 | * |
| 769 | * **Example:** Function in a VPC |
| 770 | * ```typescript |
| 771 | * const func = yield* AWS.Lambda.Function("VpcFunction", { |
| 772 | * main: "./src/handler.ts", |
| 773 | * vpc: { |
| 774 | * subnetIds: ["subnet-abc123", "subnet-def456"], |
| 775 | * securityGroupIds: ["sg-xyz789"], |
| 776 | * }, |
| 777 | * }); |
| 778 | * ``` |
| 779 | * |
| 780 | * **Example:** Async invocation retries and failure destination |
| 781 | * ```typescript |
| 782 | * const func = yield* AWS.Lambda.Function("AsyncFunction", { |
| 783 | * main: "./src/handler.ts", |
| 784 | * eventInvokeConfig: { |
| 785 | * maximumRetryAttempts: 0, |
| 786 | * maximumEventAge: "1 minute", |
| 787 | * destinationConfig: { |
| 788 | * OnFailure: { |
| 789 | * Destination: queue.queueArn, |
| 790 | * }, |
| 791 | * }, |
| 792 | * }, |
| 793 | * }); |
| 794 | * ``` |
| 795 | * |
| 796 | * ### Bundling & Tree-shaking |
| 797 | * `main` is bundled with rolldown at deploy time. Unused code is |
| 798 | * tree-shaken. `effect`, alchemy, and `@distilled.cloud` are marked |
| 799 | * pure so unused parts prune more aggressively. Your app is not |
| 800 | * marked pure. |
| 801 | * |
| 802 | * **Example:** Mark additional packages as pure |
| 803 | * Only list packages with no top-level side effects. |
| 804 | * ```typescript |
| 805 | * const func = yield* AWS.Lambda.Function("ApiFunction", { |
| 806 | * main: "./src/handler.ts", |
| 807 | * build: { |
| 808 | * pure: { packages: ["my-lib", "@my-scope/*"] }, |
| 809 | * }, |
| 810 | * }); |
| 811 | * ``` |
| 812 | * |
| 813 | * **Example:** Turn it off |
| 814 | * ```typescript |
| 815 | * const func = yield* AWS.Lambda.Function("ApiFunction", { |
| 816 | * main: "./src/handler.ts", |
| 817 | * build: { pure: false }, |
| 818 | * }); |
| 819 | * ``` |
| 820 | * |
| 821 | * ### EFS File Systems |
| 822 | * Mount an EFS access point into the function's `/mnt/…` file system. The |
| 823 | * function must be attached to a VPC that can reach an EFS mount target for |
| 824 | * the file system. |
| 825 | * |
| 826 | * **Example:** Mount an EFS access point via props |
| 827 | * ```typescript |
| 828 | * const accessPoint = yield* AWS.EFS.AccessPoint("FilesAccess", { |
| 829 | * fileSystemId: fileSystem.fileSystemId, |
| 830 | * posixUser: { uid: 1000, gid: 1000 }, |
| 831 | * }); |
| 832 | * |
| 833 | * const func = yield* AWS.Lambda.Function("FilesFunction", { |
| 834 | * main: "./src/handler.ts", |
| 835 | * vpc: { subnetIds, securityGroupIds }, |
| 836 | * fileSystemConfigs: [ |
| 837 | * // pass the AccessPoint resource itself (or its ARN via `arn`) |
| 838 | * { accessPoint, localMountPath: "/mnt/files" }, |
| 839 | * ], |
| 840 | * }); |
| 841 | * ``` |
| 842 | * |
| 843 | * **Example:** Mount via the host-agnostic EFS.mount binding |
| 844 | * `EFS.mount` wires the same mount config plus least-privilege IAM through |
| 845 | * the binding channel and works on both Lambda and ECS hosts. |
| 846 | * ```typescript |
| 847 | * export default class FilesFunction extends AWS.Lambda.Function<FilesFunction>()( |
| 848 | * "FilesFunction", |
| 849 | * { main: import.meta.url, vpc: { subnetIds, securityGroupIds } }, |
| 850 | * Effect.gen(function* () { |
| 851 | * const files = yield* AWS.EFS.mount(accessPoint, { path: "/mnt/files" }); |
| 852 | * return Effect.fn(function* (event: unknown) { |
| 853 | * return { mountedAt: files.path }; |
| 854 | * }); |
| 855 | * }).pipe(Effect.provide(AWS.EFS.MountLive)), |
| 856 | * ) {} |
| 857 | * ``` |
| 858 | * |
| 859 | * ### S3 Bindings |
| 860 | * Bind S3 operations in the init phase to give the function IAM |
| 861 | * permissions and inject the bucket name as an environment variable. |
| 862 | * |
| 863 | * **Example:** Read and write S3 objects |
| 864 | * ```typescript |
| 865 | * // init |
| 866 | * const getObject = yield* S3.GetObject(bucket); |
| 867 | * const putObject = yield* S3.PutObject(bucket); |
| 868 | * |
| 869 | * return { |
| 870 | * fetch: Effect.gen(function* () { |
| 871 | * // runtime |
| 872 | * yield* putObject({ Key: "hello.txt", Body: "Hello!" }); |
| 873 | * const obj = yield* getObject({ Key: "hello.txt" }); |
| 874 | * return HttpServerResponse.text("OK"); |
| 875 | * }), |
| 876 | * }; |
| 877 | * ``` |
| 878 | * |
| 879 | * ### DynamoDB Bindings |
| 880 | * Bind DynamoDB operations in the init phase to grant table-scoped |
| 881 | * IAM permissions. |
| 882 | * |
| 883 | * **Example:** Get and put items |
| 884 | * ```typescript |
| 885 | * // init |
| 886 | * const getItem = yield* AWS.DynamoDB.GetItem(table); |
| 887 | * const putItem = yield* AWS.DynamoDB.PutItem(table); |
| 888 | * |
| 889 | * return { |
| 890 | * fetch: Effect.gen(function* () { |
| 891 | * // runtime |
| 892 | * yield* putItem({ Item: { pk: { S: "user#1" }, name: { S: "Alice" } } }); |
| 893 | * const result = yield* getItem({ Key: { pk: { S: "user#1" } } }); |
| 894 | * return yield* HttpServerResponse.json(result.Item); |
| 895 | * }), |
| 896 | * }; |
| 897 | * ``` |
| 898 | * |
| 899 | * ### SQS Bindings |
| 900 | * Bind SQS operations in the init phase to send messages to a queue. |
| 901 | * |
| 902 | * **Example:** Send a message |
| 903 | * ```typescript |
| 904 | * // init |
| 905 | * const sendMessage = yield* SQS.SendMessage(queue); |
| 906 | * |
| 907 | * return { |
| 908 | * fetch: Effect.gen(function* () { |
| 909 | * // runtime |
| 910 | * yield* sendMessage({ |
| 911 | * MessageBody: JSON.stringify({ orderId: "123" }), |
| 912 | * }); |
| 913 | * return HttpServerResponse.text("Queued"); |
| 914 | * }), |
| 915 | * }; |
| 916 | * ``` |
| 917 | * |
| 918 | * ### SNS Bindings |
| 919 | * Bind SNS operations in the init phase to publish messages to a |
| 920 | * topic. |
| 921 | * |
| 922 | * **Example:** Publish a notification |
| 923 | * ```typescript |
| 924 | * // init |
| 925 | * const publish = yield* AWS.SNS.Publish(topic); |
| 926 | * |
| 927 | * return { |
| 928 | * fetch: Effect.gen(function* () { |
| 929 | * // runtime |
| 930 | * yield* publish({ |
| 931 | * Message: JSON.stringify({ event: "order.created" }), |
| 932 | * Subject: "OrderCreated", |
| 933 | * }); |
| 934 | * return HttpServerResponse.text("Published"); |
| 935 | * }), |
| 936 | * }; |
| 937 | * ``` |
| 938 | * |
| 939 | * ### Kinesis Bindings |
| 940 | * Bind Kinesis operations in the init phase to put records into a |
| 941 | * stream. |
| 942 | * |
| 943 | * **Example:** Put a record |
| 944 | * ```typescript |
| 945 | * // init |
| 946 | * const putRecord = yield* AWS.Kinesis.PutRecord(stream); |
| 947 | * |
| 948 | * return { |
| 949 | * fetch: Effect.gen(function* () { |
| 950 | * // runtime |
| 951 | * yield* putRecord({ |
| 952 | * PartitionKey: "order-123", |
| 953 | * Data: new TextEncoder().encode(JSON.stringify({ orderId: "123" })), |
| 954 | * }); |
| 955 | * return HttpServerResponse.text("Sent"); |
| 956 | * }), |
| 957 | * }; |
| 958 | * ``` |
| 959 | * |
| 960 | * ### Event Sources |
| 961 | * Lambda functions can be triggered by event sources like SQS queues, |
| 962 | * DynamoDB streams, S3 notifications, SNS topics, and Kinesis streams. |
| 963 | * |
| 964 | * **Example:** Process SQS messages |
| 965 | * ```typescript |
| 966 | * yield* SQS.consumeQueueMessages(queue, |
| 967 | * Effect.fn(function* (message) { |
| 968 | * yield* Effect.log(`Received: ${message.body}`); |
| 969 | * }), |
| 970 | * ); |
| 971 | * ``` |
| 972 | * |
| 973 | * **Example:** Process DynamoDB stream changes |
| 974 | * ```typescript |
| 975 | * yield* AWS.DynamoDB.consumeTableChanges(table, { |
| 976 | * StreamViewType: "NEW_AND_OLD_IMAGES", |
| 977 | * }, |
| 978 | * Effect.fn(function* (record) { |
| 979 | * yield* Effect.log(`Change: ${record.eventName}`); |
| 980 | * }), |
| 981 | * ); |
| 982 | * ``` |
| 983 | * |
| 984 | * **Example:** Process S3 notifications |
| 985 | * ```typescript |
| 986 | * yield* AWS.S3.consumeBucketEvents(bucket, { |
| 987 | * events: ["s3:ObjectCreated:*"], |
| 988 | * }, (stream) => |
| 989 | * stream.pipe( |
| 990 | * Stream.runForEach((event) => |
| 991 | * Effect.log(`New object: ${event.key}`), |
| 992 | * ), |
| 993 | * ), |
| 994 | * ); |
| 995 | * ``` |
| 996 | * |
| 997 | * @resource |
| 998 | */ |
| 999 | export const Function: Platform< |
| 1000 | Function, |
| 1001 | FunctionServices, |
| 1002 | FunctionShape, |
| 1003 | Serverless.FunctionContext, |
| 1004 | {}, |
| 1005 | FunctionZipProps |
| 1006 | > = Platform(FunctionTypeId, { |
| 1007 | createRuntimeContext: (id: string): Serverless.FunctionContext => { |
| 1008 | const listeners: Effect.Effect<Serverless.FunctionListener>[] = []; |
| 1009 | const env: Record<string, any> = {}; |
| 1010 | |
| 1011 | const ctx = { |
| 1012 | Type: FunctionTypeId, |
| 1013 | id, |
| 1014 | env, |
| 1015 | set: (id: string, output: Output.Output) => |
| 1016 | Effect.sync(() => { |
| 1017 | // Key is already canonical (see RuntimeContext.sanitizeKey); store it |
| 1018 | // verbatim. `packEnvValue` marker-packs Redacted values so they |
| 1019 | // survive the Output → Lambda env var round-trip. |
| 1020 | const key = id; |
| 1021 | env[key] = output.pipe(Output.map(packEnvValue)); |
| 1022 | return key; |
| 1023 | }), |
| 1024 | get: <T>(key: string) => |
| 1025 | // Key is already canonical (see RuntimeContext.sanitizeKey). Read |
| 1026 | // straight from `process.env` — see `unpackEnvValue` for why this |
| 1027 | // must never resolve through `Config.String`. |
| 1028 | Effect.sync(() => unpackEnvValue<T>(process.env[key])), |
| 1029 | serve: (handler: HttpEffect) => |
| 1030 | // @ts-ignore |
| 1031 | ctx.listen(makeFunctionHttpHandler(handler)), |
| 1032 | listen: (( |
| 1033 | handler: |
| 1034 | | Serverless.FunctionListener |
| 1035 | | Effect.Effect<Serverless.FunctionListener>, |
| 1036 | ) => |
| 1037 | Effect.sync(() => |
| 1038 | Effect.isEffect(handler) |
| 1039 | ? listeners.push(handler) |
| 1040 | : listeners.push(Effect.succeed(handler)), |
| 1041 | )) as any as Serverless.FunctionContext["listen"], |
| 1042 | exports: Effect.sync(() => ({ |
| 1043 | // construct an Effect that produces the Function's entrypoint |
| 1044 | // Effect<(event, context) => Promise<any>> |
| 1045 | handler: Effect.gen(function* () { |
| 1046 | const handlers = yield* Effect.all(listeners, { |
| 1047 | concurrency: "unbounded", |
| 1048 | }); |
| 1049 | // Sandbox-lifetime services, captured so each invocation can |
| 1050 | // build its telemetry exporters and run the handler effect |
| 1051 | // against the same context the init phase saw (mirrors |
| 1052 | // WorkerBridge). The build's memo map is stripped so a Layer the |
| 1053 | // user `Effect.provide`s inside a handler builds per invocation. |
| 1054 | const services = Context.omit(Layer.CurrentMemoMap)( |
| 1055 | yield* Effect.context<never>(), |
| 1056 | ); |
| 1057 | return async (event: any, context: lambda.Context): Promise<any> => { |
| 1058 | for (const handler of handlers) { |
| 1059 | const eff = handler(event); |
| 1060 | if (Effect.isEffect(eff)) { |
| 1061 | // Each invocation gets a fresh request scope, matching the |
| 1062 | // Worker / Durable Object / Workflow bridges. The scope is |
| 1063 | // settled inline before returning: a buffered Lambda |
| 1064 | // response is not released to the caller until the Invoke |
| 1065 | // phase completes, so deferring cleanup (e.g. via an |
| 1066 | // INVOKE-subscribed extension window) shows up as response |
| 1067 | // latency anyway — keep request finalizers fast. A failing |
| 1068 | // finalizer is logged and ignored so it can't mask the |
| 1069 | // invocation's outcome. |
| 1070 | const scope = Scope.makeUnsafe(); |
| 1071 | const exit = await eff.pipe( |
| 1072 | Effect.provide( |
| 1073 | Layer.mergeAll( |
| 1074 | Layer.succeed(HandlerContext, context), |
| 1075 | Layer.succeed(Scope.Scope, scope), |
| 1076 | // The configured telemetry exporters, attached to the |
| 1077 | // invocation scope by `buildEventTelemetry` so |
| 1078 | // buffered spans/logs/metrics flush when it settles |
| 1079 | // below. |
| 1080 | Layer.effectContext( |
| 1081 | buildEventTelemetry( |
| 1082 | services, |
| 1083 | scope, |
| 1084 | (ctx as Serverless.FunctionContext).telemetry, |
| 1085 | ), |
| 1086 | ), |
| 1087 | ).pipe(Layer.provideMerge(Layer.succeedContext(services))), |
| 1088 | ), |
| 1089 | Effect.tap(Effect.logDebug), |
| 1090 | Effect.runPromiseExit, |
| 1091 | ); |
| 1092 | if (!isScopeEjected(scope)) { |
| 1093 | // The HttpMiddleware tracer ends the request's root span |
| 1094 | // in a dispatcher task scheduled after the handler effect |
| 1095 | // resolves; yield one macrotask so it reaches the |
| 1096 | // telemetry exporter's buffer before the flush finalizer. |
| 1097 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 1098 | await Scope.close(scope, exit).pipe( |
| 1099 | Effect.ignoreCause({ |
| 1100 | log: "Warn", |
| 1101 | message: "Lambda invocation scope close failed", |
| 1102 | }), |
| 1103 | Effect.runPromise, |
| 1104 | ); |
| 1105 | } |
| 1106 | if (Exit.isSuccess(exit)) { |
| 1107 | return exit.value; |
| 1108 | } |
| 1109 | throw Cause.squash(exit.cause); |
| 1110 | } |
| 1111 | } |
| 1112 | throw new Error("No event handler found"); |
| 1113 | }; |
| 1114 | }), |
| 1115 | })), |
| 1116 | }; |
| 1117 | return ctx; |
| 1118 | }, |
| 1119 | }); |
| 1120 | |
| 1121 | export const FunctionProvider = () => |
| 1122 | Provider.effect( |
| 1123 | Function, |
| 1124 | Effect.gen(function* () { |
| 1125 | const stack = yield* Stack; |
| 1126 | |
| 1127 | // Code bundling lives in FunctionBundle.ts so the floci local |
| 1128 | // provider's watch loop can rebuild the identical artifact. |
| 1129 | const { bundleCode } = yield* makeFunctionBundler; |
| 1130 | const functionImage = yield* makeFunctionImage; |
| 1131 | const alchemyEnv = { |
| 1132 | ALCHEMY_STACK_NAME: stack.name, |
| 1133 | ALCHEMY_STAGE: stack.stage, |
| 1134 | ALCHEMY_PHASE: "runtime", |
| 1135 | }; |
| 1136 | |
| 1137 | const createFunctionName = ( |
| 1138 | id: string, |
| 1139 | functionName: string | undefined, |
| 1140 | ) => |
| 1141 | Effect.gen(function* () { |
| 1142 | return ( |
| 1143 | functionName ?? (yield* createPhysicalName({ id, maxLength: 64 })) |
| 1144 | ); |
| 1145 | }); |
| 1146 | |
| 1147 | const createRoleName = (id: string) => |
| 1148 | createPhysicalName({ id, maxLength: 64 }); |
| 1149 | |
| 1150 | const createPolicyName = (id: string) => |
| 1151 | createPhysicalName({ id, maxLength: 128 }); |
| 1152 | |
| 1153 | const hashBundle = (code: Uint8Array<ArrayBufferLike>) => sha256(code); |
| 1154 | |
| 1155 | const createNames = (id: string, functionName: string | undefined) => |
| 1156 | Effect.gen(function* () { |
| 1157 | const { accountId, region } = yield* AWSEnvironment.current; |
| 1158 | const roleName = yield* createRoleName(id); |
| 1159 | const policyName = yield* createPolicyName(id); |
| 1160 | const fn = yield* createFunctionName(id, functionName); |
| 1161 | return { |
| 1162 | roleName, |
| 1163 | policyName, |
| 1164 | functionName: fn, |
| 1165 | roleArn: `arn:aws:iam::${accountId}:role/${roleName}`, |
| 1166 | functionArn: `arn:aws:lambda:${region}:${accountId}:function:${fn}`, |
| 1167 | }; |
| 1168 | }); |
| 1169 | |
| 1170 | const attachBindings = Effect.fn(function* ({ |
| 1171 | roleName, |
| 1172 | policyName, |
| 1173 | // functionArn, |
| 1174 | // functionName, |
| 1175 | bindings, |
| 1176 | }: { |
| 1177 | roleName: string; |
| 1178 | policyName: string; |
| 1179 | functionArn: string; |
| 1180 | functionName: string; |
| 1181 | bindings: ResourceBinding<Function["Binding"]>[]; |
| 1182 | }) { |
| 1183 | const activeBindings = bindings.filter( |
| 1184 | ( |
| 1185 | binding: ResourceBinding<Function["Binding"]> & { action?: string }, |
| 1186 | ) => binding.action !== "delete", |
| 1187 | ); |
| 1188 | const env = activeBindings |
| 1189 | .map((binding) => binding?.data?.env) |
| 1190 | .reduce((acc, env) => ({ ...acc, ...env }), {}); |
| 1191 | const policyStatements = activeBindings.flatMap( |
| 1192 | (binding) => |
| 1193 | binding?.data?.policyStatements?.map( |
| 1194 | (stmt: IAM.PolicyStatement) => ({ |
| 1195 | ...stmt, |
| 1196 | Sid: stmt.Sid?.replace(/[^A-Za-z0-9]+/gi, ""), |
| 1197 | }), |
| 1198 | ) ?? [], |
| 1199 | ); |
| 1200 | // VPC attachments requested through the binding channel (DECISION |
| 1201 | // #5) — set-union across bindings; merged with the `vpc` prop in |
| 1202 | // `reconcile`. |
| 1203 | const vpcRequests = activeBindings.flatMap((binding) => |
| 1204 | binding?.data?.vpc ? [binding.data.vpc] : [], |
| 1205 | ); |
| 1206 | const vpc = |
| 1207 | vpcRequests.length > 0 |
| 1208 | ? { |
| 1209 | subnetIds: [ |
| 1210 | ...new Set(vpcRequests.flatMap((v) => v.subnetIds)), |
| 1211 | ], |
| 1212 | securityGroupIds: [ |
| 1213 | ...new Set(vpcRequests.flatMap((v) => v.securityGroupIds)), |
| 1214 | ], |
| 1215 | } |
| 1216 | : undefined; |
| 1217 | // EFS mounts requested through the binding channel (`EFS.Mount`) — |
| 1218 | // deduped by mount path; merged with the `fileSystemConfigs` prop in |
| 1219 | // `reconcile`. |
| 1220 | const fileSystemConfigs = [ |
| 1221 | ...new Map( |
| 1222 | activeBindings |
| 1223 | .flatMap((binding) => binding?.data?.fileSystemConfigs ?? []) |
| 1224 | .map((config) => [config.localMountPath, config] as const), |
| 1225 | ).values(), |
| 1226 | ]; |
| 1227 | |
| 1228 | if (policyStatements.length > 0) { |
| 1229 | yield* iam.putRolePolicy({ |
| 1230 | RoleName: roleName, |
| 1231 | PolicyName: policyName, |
| 1232 | PolicyDocument: JSON.stringify({ |
| 1233 | Version: "2012-10-17", |
| 1234 | Statement: policyStatements, |
| 1235 | } satisfies IAM.PolicyDocument), |
| 1236 | }); |
| 1237 | } else { |
| 1238 | yield* iam |
| 1239 | .deleteRolePolicy({ |
| 1240 | RoleName: roleName, |
| 1241 | PolicyName: policyName, |
| 1242 | }) |
| 1243 | .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void)); |
| 1244 | } |
| 1245 | |
| 1246 | return { env, vpc, fileSystemConfigs }; |
| 1247 | }); |
| 1248 | |
| 1249 | const xrayWriteAccessPolicyArn = |
| 1250 | "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess"; |
| 1251 | |
| 1252 | /** |
| 1253 | * Converge the X-Ray write managed policy on the execution role with |
| 1254 | * the desired tracing mode. Attaching is idempotent; detaching is only |
| 1255 | * attempted when the previous state may have had it attached (routine |
| 1256 | * update from `Active`, or adoption where the prior props are unknown) |
| 1257 | * to avoid a wasted IAM call on every deploy. |
| 1258 | */ |
| 1259 | const syncTracingPolicy = Effect.fn(function* ({ |
| 1260 | roleName, |
| 1261 | tracing, |
| 1262 | mayHaveBeenActive, |
| 1263 | }: { |
| 1264 | roleName: string; |
| 1265 | tracing: FunctionProps["tracing"]; |
| 1266 | mayHaveBeenActive: boolean; |
| 1267 | }) { |
| 1268 | if (tracing === "Active") { |
| 1269 | yield* iam.attachRolePolicy({ |
| 1270 | RoleName: roleName, |
| 1271 | PolicyArn: xrayWriteAccessPolicyArn, |
| 1272 | }); |
| 1273 | } else if (mayHaveBeenActive) { |
| 1274 | yield* iam |
| 1275 | .detachRolePolicy({ |
| 1276 | RoleName: roleName, |
| 1277 | PolicyArn: xrayWriteAccessPolicyArn, |
| 1278 | }) |
| 1279 | .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void)); |
| 1280 | } |
| 1281 | }); |
| 1282 | |
| 1283 | const createRoleIfNotExists = Effect.fn(function* ({ |
| 1284 | id, |
| 1285 | roleName, |
| 1286 | vpc, |
| 1287 | }: { |
| 1288 | id: string; |
| 1289 | roleName: string; |
| 1290 | vpc?: FunctionProps["vpc"]; |
| 1291 | }) { |
| 1292 | yield* Effect.logDebug(`creating role ${id}`); |
| 1293 | const tags = yield* createInternalTags(id); |
| 1294 | // Observe before ensure. Reconcile calls this even with persisted |
| 1295 | // output so an execution role deleted out-of-band is recreated, but |
| 1296 | // the normal update path avoids provoking an expected |
| 1297 | // EntityAlreadyExists exception (important under high concurrency). |
| 1298 | let role = yield* iam |
| 1299 | .getRole({ RoleName: roleName }) |
| 1300 | .pipe( |
| 1301 | Effect.catchTag("NoSuchEntityException", () => |
| 1302 | Effect.succeed(undefined), |
| 1303 | ), |
| 1304 | ); |
| 1305 | if (role === undefined) { |
| 1306 | // Engine has cleared us via `read` — foreign-tagged functions are |
| 1307 | // surfaced as `Unowned` and require `--adopt`. On a race between |
| 1308 | // observe and create, read the role created by the peer reconciler. |
| 1309 | role = yield* iam |
| 1310 | .createRole({ |
| 1311 | RoleName: roleName, |
| 1312 | AssumeRolePolicyDocument: JSON.stringify({ |
| 1313 | Version: "2012-10-17", |
| 1314 | Statement: [ |
| 1315 | { |
| 1316 | Effect: "Allow", |
| 1317 | Principal: { |
| 1318 | Service: "lambda.amazonaws.com", |
| 1319 | }, |
| 1320 | Action: "sts:AssumeRole", |
| 1321 | }, |
| 1322 | ], |
| 1323 | }), |
| 1324 | Tags: createTagsList(tags), |
| 1325 | }) |
| 1326 | .pipe( |
| 1327 | Effect.catchTag("EntityAlreadyExistsException", () => |
| 1328 | iam.getRole({ RoleName: roleName }), |
| 1329 | ), |
| 1330 | ); |
| 1331 | } |
| 1332 | |
| 1333 | yield* Effect.logDebug(`attaching policy ${id}`); |
| 1334 | yield* iam |
| 1335 | .attachRolePolicy({ |
| 1336 | RoleName: roleName, |
| 1337 | PolicyArn: |
| 1338 | "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", |
| 1339 | }) |
| 1340 | .pipe(Effect.tapError(Effect.logDebug), Effect.tap(Effect.logDebug)); |
| 1341 | |
| 1342 | if (vpc) { |
| 1343 | yield* iam |
| 1344 | .attachRolePolicy({ |
| 1345 | RoleName: roleName, |
| 1346 | PolicyArn: |
| 1347 | "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole", |
| 1348 | }) |
| 1349 | .pipe( |
| 1350 | Effect.tapError(Effect.logDebug), |
| 1351 | Effect.tap(Effect.logDebug), |
| 1352 | ); |
| 1353 | } |
| 1354 | |
| 1355 | yield* Effect.logDebug(`attached policy ${id}`); |
| 1356 | return role; |
| 1357 | }); |
| 1358 | |
| 1359 | const withNodeSourceMaps = ( |
| 1360 | env: Record<string, string> | undefined, |
| 1361 | props: FunctionZipProps, |
| 1362 | ) => { |
| 1363 | const sourcemap = props.build?.output?.sourcemap ?? true; |
| 1364 | const uploadSourceMap = props.uploadSourceMap ?? true; |
| 1365 | const shouldEnableSourceMaps = |
| 1366 | sourcemap === "inline" || |
| 1367 | (uploadSourceMap && (sourcemap === true || sourcemap === "hidden")); |
| 1368 | |
| 1369 | if (!shouldEnableSourceMaps) { |
| 1370 | return env; |
| 1371 | } |
| 1372 | |
| 1373 | const current = env?.NODE_OPTIONS; |
| 1374 | if (current?.split(/\s+/).includes("--enable-source-maps")) { |
| 1375 | return env; |
| 1376 | } |
| 1377 | |
| 1378 | return { |
| 1379 | ...env, |
| 1380 | NODE_OPTIONS: current |
| 1381 | ? `${current} --enable-source-maps` |
| 1382 | : "--enable-source-maps", |
| 1383 | }; |
| 1384 | }; |
| 1385 | |
| 1386 | const retryFunctionMutation = Effect.retry({ |
| 1387 | while: (e: any) => |
| 1388 | e._tag === "ResourceConflictException" || |
| 1389 | e._tag === "TooManyRequestsException", |
| 1390 | schedule: Schedule.max([ |
| 1391 | Schedule.exponential(100), |
| 1392 | Schedule.recurs(30), |
| 1393 | ]), |
| 1394 | }) as <A, R, Err>( |
| 1395 | self: Effect.Effect<A, Err, R>, |
| 1396 | ) => Effect.Effect<A, Err, R>; |
| 1397 | |
| 1398 | const waitForFunctionUpdate = Effect.fn(function* ( |
| 1399 | functionName: string, |
| 1400 | session: { note: (note: string) => Effect.Effect<void> }, |
| 1401 | vpc: boolean, |
| 1402 | ) { |
| 1403 | return yield* Effect.gen(function* () { |
| 1404 | const configuration = (yield* Lambda.getFunction({ |
| 1405 | FunctionName: functionName, |
| 1406 | })).Configuration; |
| 1407 | if ( |
| 1408 | configuration?.State === "Failed" || |
| 1409 | configuration?.LastUpdateStatus === "Failed" |
| 1410 | ) { |
| 1411 | return yield* new FunctionUpdateFailed({ |
| 1412 | functionName, |
| 1413 | reason: |
| 1414 | configuration.LastUpdateStatusReason ?? |
| 1415 | configuration.StateReason, |
| 1416 | }); |
| 1417 | } |
| 1418 | if ( |
| 1419 | configuration?.State === "Active" && |
| 1420 | (configuration.LastUpdateStatus === undefined || |
| 1421 | configuration.LastUpdateStatus === "Successful") |
| 1422 | ) { |
| 1423 | return; |
| 1424 | } |
| 1425 | return yield* new FunctionUpdatePending({ |
| 1426 | functionName, |
| 1427 | state: configuration?.State, |
| 1428 | stateReason: configuration?.StateReason, |
| 1429 | lastUpdateStatus: configuration?.LastUpdateStatus, |
| 1430 | lastUpdateStatusReason: configuration?.LastUpdateStatusReason, |
| 1431 | }); |
| 1432 | }).pipe( |
| 1433 | Effect.retry({ |
| 1434 | while: (error) => error._tag === "FunctionUpdatePending", |
| 1435 | schedule: Schedule.spaced("2 seconds").pipe( |
| 1436 | Schedule.tap(({ attempt }) => |
| 1437 | session.note( |
| 1438 | `Waiting for Lambda function update: ${functionName} (${attempt * 2}s)`, |
| 1439 | ), |
| 1440 | ), |
| 1441 | ), |
| 1442 | // New VPC attachments can spend several minutes provisioning Hyperplane ENIs. |
| 1443 | times: vpc ? 150 : 30, |
| 1444 | }), |
| 1445 | ); |
| 1446 | }); |
| 1447 | |
| 1448 | const getReservedConcurrentExecutions = Effect.fn(function* ( |
| 1449 | functionName: string, |
| 1450 | ) { |
| 1451 | return yield* Lambda.getFunctionConcurrency({ |
| 1452 | FunctionName: functionName, |
| 1453 | }).pipe( |
| 1454 | Effect.map((config) => config.ReservedConcurrentExecutions), |
| 1455 | Effect.catchTag("ResourceNotFoundException", () => |
| 1456 | Effect.succeed(undefined), |
| 1457 | ), |
| 1458 | ); |
| 1459 | }); |
| 1460 | |
| 1461 | const syncReservedConcurrentExecutions = Effect.fn(function* ({ |
| 1462 | functionName, |
| 1463 | reservedConcurrentExecutions, |
| 1464 | }: { |
| 1465 | functionName: string; |
| 1466 | reservedConcurrentExecutions: number | undefined; |
| 1467 | }) { |
| 1468 | const current = yield* getReservedConcurrentExecutions(functionName); |
| 1469 | if (current === reservedConcurrentExecutions) { |
| 1470 | return current; |
| 1471 | } |
| 1472 | |
| 1473 | if (reservedConcurrentExecutions === undefined) { |
| 1474 | yield* Lambda.deleteFunctionConcurrency({ |
| 1475 | FunctionName: functionName, |
| 1476 | }).pipe( |
| 1477 | retryFunctionMutation, |
| 1478 | Effect.catchTag("ResourceNotFoundException", () => Effect.void), |
| 1479 | ); |
| 1480 | return undefined; |
| 1481 | } |
| 1482 | |
| 1483 | const updated = yield* Lambda.putFunctionConcurrency({ |
| 1484 | FunctionName: functionName, |
| 1485 | ReservedConcurrentExecutions: reservedConcurrentExecutions, |
| 1486 | }).pipe(retryFunctionMutation); |
| 1487 | return ( |
| 1488 | updated.ReservedConcurrentExecutions ?? reservedConcurrentExecutions |
| 1489 | ); |
| 1490 | }); |
| 1491 | |
| 1492 | type FunctionDeploymentCode = |
| 1493 | | { |
| 1494 | packageType: "Zip"; |
| 1495 | archive: Uint8Array<ArrayBufferLike>; |
| 1496 | hash: string; |
| 1497 | } |
| 1498 | | { |
| 1499 | packageType: "Image"; |
| 1500 | imageUri: string; |
| 1501 | hash: string; |
| 1502 | }; |
| 1503 | |
| 1504 | interface PreparedFunctionCode { |
| 1505 | deployment: FunctionDeploymentCode; |
| 1506 | attributes: Function["Attributes"]["code"]; |
| 1507 | } |
| 1508 | |
| 1509 | const prepareImageFunctionCode = Effect.fn(function* ({ |
| 1510 | id, |
| 1511 | props, |
| 1512 | previousImage, |
| 1513 | session, |
| 1514 | }: { |
| 1515 | id: string; |
| 1516 | props: FunctionImageProps; |
| 1517 | previousImage?: Omit<FunctionImageAttributes, "hash">; |
| 1518 | session: { note: (note: string) => Effect.Effect<void> }; |
| 1519 | }) { |
| 1520 | const { hash, ...image } = yield* functionImage.resolve({ |
| 1521 | id, |
| 1522 | source: props.image, |
| 1523 | architecture: props.architecture, |
| 1524 | previousImage, |
| 1525 | session, |
| 1526 | }); |
| 1527 | return { |
| 1528 | deployment: { |
| 1529 | packageType: "Image", |
| 1530 | imageUri: image.imageUri, |
| 1531 | hash, |
| 1532 | }, |
| 1533 | attributes: { hash, image }, |
| 1534 | } satisfies PreparedFunctionCode; |
| 1535 | }); |
| 1536 | |
| 1537 | const preparePrecreatedFunctionCode = Effect.fn(function* ({ |
| 1538 | id, |
| 1539 | props, |
| 1540 | session, |
| 1541 | }: { |
| 1542 | id: string; |
| 1543 | props: FunctionProps; |
| 1544 | session: { note: (note: string) => Effect.Effect<void> }; |
| 1545 | }) { |
| 1546 | if (isFunctionImageProps(props)) { |
| 1547 | return yield* prepareImageFunctionCode({ id, props, session }); |
| 1548 | } |
| 1549 | |
| 1550 | // The precreated stub responds 503 until reconciliation installs the |
| 1551 | // real handler and waits for its configuration to become active. |
| 1552 | const code = new TextEncoder().encode( |
| 1553 | `export default () => ({ statusCode: 503, headers: { "content-type": "application/json" }, body: JSON.stringify({ error: "function initializing" }) })`, |
| 1554 | ); |
| 1555 | const archive = yield* zipCode(code); |
| 1556 | const hash = yield* hashBundle(code); |
| 1557 | return { |
| 1558 | deployment: { |
| 1559 | packageType: "Zip", |
| 1560 | archive, |
| 1561 | hash, |
| 1562 | }, |
| 1563 | attributes: { hash }, |
| 1564 | } satisfies PreparedFunctionCode; |
| 1565 | }); |
| 1566 | |
| 1567 | const prepareFunctionCode = Effect.fn(function* ({ |
| 1568 | id, |
| 1569 | props, |
| 1570 | previousImage, |
| 1571 | session, |
| 1572 | }: { |
| 1573 | id: string; |
| 1574 | props: FunctionProps; |
| 1575 | previousImage?: Omit<FunctionImageAttributes, "hash">; |
| 1576 | session: { note: (note: string) => Effect.Effect<void> }; |
| 1577 | }) { |
| 1578 | if (isFunctionImageProps(props)) { |
| 1579 | return yield* prepareImageFunctionCode({ |
| 1580 | id, |
| 1581 | props, |
| 1582 | previousImage, |
| 1583 | session, |
| 1584 | }); |
| 1585 | } |
| 1586 | |
| 1587 | const { identityHash, buildArchive } = yield* bundleCode(id, props); |
| 1588 | const { archive, archiveHash } = yield* buildArchive; |
| 1589 | return { |
| 1590 | deployment: { |
| 1591 | packageType: "Zip", |
| 1592 | archive, |
| 1593 | hash: archiveHash, |
| 1594 | }, |
| 1595 | attributes: { hash: identityHash }, |
| 1596 | } satisfies PreparedFunctionCode; |
| 1597 | }); |
| 1598 | |
| 1599 | const createOrUpdateFunction: (input: { |
| 1600 | id: string; |
| 1601 | news: FunctionProps; |
| 1602 | roleArn: string; |
| 1603 | code: FunctionDeploymentCode; |
| 1604 | env: Record<string, string> | undefined; |
| 1605 | functionName: string; |
| 1606 | preferUpdate?: boolean; |
| 1607 | // Resolved EFS mounts. `undefined` leaves the function's existing |
| 1608 | // config untouched (precreate stub); `[]` explicitly clears mounts. |
| 1609 | fileSystemConfigs?: Lambda.FileSystemConfig[]; |
| 1610 | // Effective VPC attachment (prop ∪ binding-channel requests). |
| 1611 | // Omitted for the precreate stub: `news` is UNRESOLVED at precreate |
| 1612 | // (Output-valued subnet/security-group ids would fail to serialize), |
| 1613 | // and the stub doesn't need connectivity — `reconcile` attaches the |
| 1614 | // resolved VPC config afterwards. |
| 1615 | vpc?: FunctionProps["vpc"]; |
| 1616 | session: { note: (note: string) => Effect.Effect<void> }; |
| 1617 | }) => Effect.Effect< |
| 1618 | void, |
| 1619 | any, |
| 1620 | Credentials | Region | HttpClient | Stack | Stage | AWSEnvironment |
| 1621 | > = Effect.fn(function* ({ |
| 1622 | id, |
| 1623 | news, |
| 1624 | roleArn, |
| 1625 | code, |
| 1626 | env, |
| 1627 | functionName, |
| 1628 | preferUpdate, |
| 1629 | fileSystemConfigs, |
| 1630 | vpc, |
| 1631 | session, |
| 1632 | }: { |
| 1633 | id: string; |
| 1634 | news: FunctionProps; |
| 1635 | roleArn: string; |
| 1636 | code: FunctionDeploymentCode; |
| 1637 | env: Record<string, string> | undefined; |
| 1638 | functionName: string; |
| 1639 | preferUpdate?: boolean; |
| 1640 | fileSystemConfigs?: Lambda.FileSystemConfig[]; |
| 1641 | vpc?: FunctionProps["vpc"]; |
| 1642 | session: { note: (note: string) => Effect.Effect<void> }; |
| 1643 | }) { |
| 1644 | yield* Effect.logDebug(`creating function ${id}`); |
| 1645 | const waitStartedAt = Date.now(); |
| 1646 | |
| 1647 | const isRolePropagationError = < |
| 1648 | E extends Lambda.UpdateFunctionCodeError | Lambda.CreateFunctionError, |
| 1649 | >( |
| 1650 | e: E, |
| 1651 | ) => |
| 1652 | e._tag === "InvalidParameterValueException" && |
| 1653 | (e.message?.includes("cannot be assumed by Lambda") || |
| 1654 | // Freshly attached AWSLambdaVPCAccessExecutionRole still |
| 1655 | // propagating when a VPC-attached function is created/updated. |
| 1656 | e.message?.includes( |
| 1657 | "does not have permissions to call CreateNetworkInterface", |
| 1658 | ) || |
| 1659 | (e.message?.includes("KMS key is invalid for CreateGrant") && |
| 1660 | e.message?.includes("ARN does not refer to a valid principal"))); |
| 1661 | |
| 1662 | const isSecurityGroupPropagationError = ( |
| 1663 | e: Lambda.CreateFunctionError, |
| 1664 | ) => |
| 1665 | e._tag === "InvalidParameterValueException" && |
| 1666 | e.message?.includes("InvalidGroup.NotFound"); |
| 1667 | |
| 1668 | const noteCreateDependencyWait = () => |
| 1669 | session.note( |
| 1670 | `Waiting for Lambda creation dependencies to propagate: ${functionName} (${Math.ceil((Date.now() - waitStartedAt) / 1000)}s)`, |
| 1671 | ); |
| 1672 | |
| 1673 | const tags = yield* createInternalTags(id); |
| 1674 | |
| 1675 | const codeLocation = yield* Effect.gen(function* () { |
| 1676 | if (code.packageType === "Image") { |
| 1677 | return { ImageUri: code.imageUri } as const; |
| 1678 | } |
| 1679 | // Try to use S3 if the assets bucket is available, otherwise fall |
| 1680 | // back to an inline ZipFile. |
| 1681 | const assets = (yield* Effect.serviceOption(Assets)).pipe( |
| 1682 | Option.getOrUndefined, |
| 1683 | ); |
| 1684 | if (!assets) { |
| 1685 | return { ZipFile: code.archive } as const; |
| 1686 | } |
| 1687 | const key = yield* assets.uploadAsset(code.hash, code.archive); |
| 1688 | yield* Effect.logDebug( |
| 1689 | `Using S3 for code: s3://${yield* assets.bucketName}/${key}`, |
| 1690 | ); |
| 1691 | return { |
| 1692 | S3Bucket: yield* assets.bucketName, |
| 1693 | S3Key: key, |
| 1694 | } as const; |
| 1695 | }); |
| 1696 | const runtimeEnv = isFunctionImageProps(news) |
| 1697 | ? env |
| 1698 | : withNodeSourceMaps(env, news); |
| 1699 | |
| 1700 | const createFunctionRequest: CreateFunctionRequest = { |
| 1701 | FunctionName: functionName, |
| 1702 | Role: roleArn, |
| 1703 | Code: codeLocation, |
| 1704 | ...(isFunctionImageProps(news) |
| 1705 | ? { |
| 1706 | PackageType: "Image" as const, |
| 1707 | ImageConfig: toLambdaImageConfig(news.image), |
| 1708 | } |
| 1709 | : { |
| 1710 | // Effect-mode functions are wrapped in a generated entry whose |
| 1711 | // ONLY export is `default` — `handler` names an export of the |
| 1712 | // USER's module and can only address it when the module is |
| 1713 | // bundled as-is (isExternal). Honoring it in Effect mode |
| 1714 | // deploys a Lambda that dies at init with |
| 1715 | // Runtime.HandlerNotFound. |
| 1716 | Handler: handlerStringOf(news), |
| 1717 | Runtime: news.runtime ?? "nodejs24.x", |
| 1718 | PackageType: "Zip" as const, |
| 1719 | }), |
| 1720 | Architectures: [news.architecture ?? "x86_64"], |
| 1721 | MemorySize: news.memorySize, |
| 1722 | // Always explicit: `UpdateFunctionConfiguration` treats an omitted |
| 1723 | // `Layers` as "leave as-is", so removing the prop would strand the |
| 1724 | // previously-attached layers. |
| 1725 | Layers: isFunctionImageProps(news) |
| 1726 | ? undefined |
| 1727 | : (news.layers ?? []).map(layerVersionArnOf), |
| 1728 | Environment: runtimeEnv |
| 1729 | ? { |
| 1730 | Variables: { |
| 1731 | ...runtimeEnv, |
| 1732 | ...alchemyEnv, |
| 1733 | }, |
| 1734 | } |
| 1735 | : undefined, |
| 1736 | Tags: tags, |
| 1737 | Timeout: toTimeoutSeconds(news.timeout), |
| 1738 | // Always explicit so removing the `tracing` prop converges back to |
| 1739 | // the AWS default on update. |
| 1740 | TracingConfig: { Mode: news.tracing ?? "PassThrough" }, |
| 1741 | // Durability is create-time-only; a presence flip is a replacement |
| 1742 | // (see `diff`), so passing the same value on update is a no-op. |
| 1743 | DurableConfig: news.durableConfig, |
| 1744 | VpcConfig: vpc |
| 1745 | ? { |
| 1746 | SubnetIds: vpc.subnetIds, |
| 1747 | SecurityGroupIds: vpc.securityGroupIds, |
| 1748 | } |
| 1749 | : undefined, |
| 1750 | FileSystemConfigs: fileSystemConfigs, |
| 1751 | }; |
| 1752 | |
| 1753 | const getAndUpdate = Lambda.getFunction({ |
| 1754 | FunctionName: functionName, |
| 1755 | }).pipe( |
| 1756 | // If it exists and contains these tags, we will assume it was created |
| 1757 | // by alchemy but state was lost, so if it exists, let's adopt it. |
| 1758 | // Some backends (e.g. local emulators) omit `Tags` on GetFunction — |
| 1759 | // fall back to ListTags before concluding the function is foreign. |
| 1760 | Effect.flatMap((f) => |
| 1761 | f.Tags !== undefined |
| 1762 | ? Effect.succeed(hasTags(tags, f.Tags)) |
| 1763 | : Lambda.listTags({ |
| 1764 | Resource: f.Configuration?.FunctionArn ?? "", |
| 1765 | }).pipe( |
| 1766 | Effect.map((r) => hasTags(tags, r.Tags ?? {})), |
| 1767 | Effect.catchTag("ResourceNotFoundException", () => |
| 1768 | Effect.succeed(false), |
| 1769 | ), |
| 1770 | ), |
| 1771 | ), |
| 1772 | Effect.filterOrFail( |
| 1773 | (owned) => owned, |
| 1774 | () => |
| 1775 | // TODO(sam): add custom |
| 1776 | new Error("Function tags do not match expected values"), |
| 1777 | ), |
| 1778 | Effect.flatMap(() => |
| 1779 | Effect.gen(function* () { |
| 1780 | yield* Effect.logDebug(`updating function code ${id}`); |
| 1781 | yield* Lambda.updateFunctionCode({ |
| 1782 | FunctionName: createFunctionRequest.FunctionName, |
| 1783 | Architectures: createFunctionRequest.Architectures, |
| 1784 | ...codeLocation, |
| 1785 | }).pipe( |
| 1786 | Effect.tapError((e) => |
| 1787 | isRolePropagationError(e) |
| 1788 | ? noteCreateDependencyWait() |
| 1789 | : Effect.void, |
| 1790 | ), |
| 1791 | Effect.retry({ |
| 1792 | while: (e) => |
| 1793 | e._tag === "ResourceConflictException" || |
| 1794 | isRolePropagationError(e), |
| 1795 | schedule: Schedule.exponential(100), |
| 1796 | }), |
| 1797 | ); |
| 1798 | yield* Effect.logDebug(`updated function code ${id}`); |
| 1799 | yield* Lambda.updateFunctionConfiguration({ |
| 1800 | FunctionName: createFunctionRequest.FunctionName, |
| 1801 | DeadLetterConfig: createFunctionRequest.DeadLetterConfig, |
| 1802 | Description: createFunctionRequest.Description, |
| 1803 | Environment: createFunctionRequest.Environment, |
| 1804 | EphemeralStorage: createFunctionRequest.EphemeralStorage, |
| 1805 | FileSystemConfigs: createFunctionRequest.FileSystemConfigs, |
| 1806 | Handler: createFunctionRequest.Handler, |
| 1807 | ImageConfig: imageConfigForUpdate(news), |
| 1808 | KMSKeyArn: createFunctionRequest.KMSKeyArn, |
| 1809 | Layers: createFunctionRequest.Layers, |
| 1810 | LoggingConfig: createFunctionRequest.LoggingConfig, |
| 1811 | MemorySize: createFunctionRequest.MemorySize, |
| 1812 | // RevisionId: "???" |
| 1813 | Role: createFunctionRequest.Role, |
| 1814 | Runtime: createFunctionRequest.Runtime, |
| 1815 | SnapStart: createFunctionRequest.SnapStart, |
| 1816 | Timeout: createFunctionRequest.Timeout, |
| 1817 | TracingConfig: createFunctionRequest.TracingConfig, |
| 1818 | VpcConfig: createFunctionRequest.VpcConfig, |
| 1819 | DurableConfig: createFunctionRequest.DurableConfig, |
| 1820 | }).pipe( |
| 1821 | Effect.tapError((e) => |
| 1822 | isRolePropagationError(e) |
| 1823 | ? noteCreateDependencyWait() |
| 1824 | : Effect.void, |
| 1825 | ), |
| 1826 | Effect.retry({ |
| 1827 | while: (e) => |
| 1828 | e._tag === "ResourceConflictException" || |
| 1829 | isRolePropagationError(e), |
| 1830 | schedule: Schedule.exponential(100), |
| 1831 | }), |
| 1832 | ); |
| 1833 | yield* Effect.logDebug(`updated function configuration ${id}`); |
| 1834 | }), |
| 1835 | ), |
| 1836 | ) as Effect.Effect<any, any, Credentials | Region | HttpClient>; |
| 1837 | |
| 1838 | const create = Lambda.createFunction(createFunctionRequest).pipe( |
| 1839 | Effect.tapError((e) => |
| 1840 | Effect.gen(function* () { |
| 1841 | yield* Effect.logDebug(e); |
| 1842 | }), |
| 1843 | ), |
| 1844 | Effect.retry({ |
| 1845 | while: (e) => |
| 1846 | isRolePropagationError(e) || isSecurityGroupPropagationError(e), |
| 1847 | schedule: Schedule.fixed(1000).pipe( |
| 1848 | Schedule.tap(() => noteCreateDependencyWait()), |
| 1849 | ), |
| 1850 | }), |
| 1851 | Effect.catchTags({ |
| 1852 | ResourceConflictException: () => getAndUpdate, |
| 1853 | }), |
| 1854 | ) as Effect.Effect<any, any, Credentials | Region | HttpClient>; |
| 1855 | |
| 1856 | if (preferUpdate) { |
| 1857 | yield* getAndUpdate.pipe( |
| 1858 | Effect.catchTags({ |
| 1859 | ResourceNotFoundException: () => create, |
| 1860 | }), |
| 1861 | ); |
| 1862 | } else { |
| 1863 | yield* create; |
| 1864 | } |
| 1865 | }); |
| 1866 | |
| 1867 | const publicUrlAccessStatementId = "FunctionURLAllowPublicAccess"; |
| 1868 | const publicUrlInvokeStatementId = "FunctionURLAllowPublicInvoke"; |
| 1869 | |
| 1870 | const removePublicFunctionUrlPermissions = Effect.fn(function* ( |
| 1871 | functionName: string, |
| 1872 | ) { |
| 1873 | yield* Effect.all( |
| 1874 | [ |
| 1875 | Lambda.removePermission({ |
| 1876 | FunctionName: functionName, |
| 1877 | StatementId: publicUrlAccessStatementId, |
| 1878 | }).pipe( |
| 1879 | Effect.catchTag("ResourceNotFoundException", () => Effect.void), |
| 1880 | ), |
| 1881 | Lambda.removePermission({ |
| 1882 | FunctionName: functionName, |
| 1883 | StatementId: publicUrlInvokeStatementId, |
| 1884 | }).pipe( |
| 1885 | Effect.catchTag("ResourceNotFoundException", () => Effect.void), |
| 1886 | ), |
| 1887 | ], |
| 1888 | { concurrency: "unbounded" }, |
| 1889 | ); |
| 1890 | }); |
| 1891 | |
| 1892 | const upsertPermission = (permission: Lambda.AddPermissionRequest) => |
| 1893 | Lambda.addPermission(permission).pipe( |
| 1894 | Effect.catchTag("ResourceConflictException", () => |
| 1895 | Effect.gen(function* () { |
| 1896 | yield* Lambda.removePermission({ |
| 1897 | FunctionName: permission.FunctionName, |
| 1898 | StatementId: permission.StatementId, |
| 1899 | }).pipe( |
| 1900 | Effect.catchTag("ResourceNotFoundException", () => Effect.void), |
| 1901 | ); |
| 1902 | yield* Lambda.addPermission(permission); |
| 1903 | }), |
| 1904 | ), |
| 1905 | retryFunctionMutation, |
| 1906 | ); |
| 1907 | |
| 1908 | const upsertPublicFunctionUrlPermissions = Effect.fn(function* ( |
| 1909 | functionName: string, |
| 1910 | ) { |
| 1911 | yield* Effect.all( |
| 1912 | [ |
| 1913 | upsertPermission({ |
| 1914 | FunctionName: functionName, |
| 1915 | StatementId: publicUrlAccessStatementId, |
| 1916 | Action: "lambda:InvokeFunctionUrl", |
| 1917 | Principal: "*", |
| 1918 | FunctionUrlAuthType: "NONE", |
| 1919 | }), |
| 1920 | upsertPermission({ |
| 1921 | FunctionName: functionName, |
| 1922 | StatementId: publicUrlInvokeStatementId, |
| 1923 | Action: "lambda:InvokeFunction", |
| 1924 | Principal: "*", |
| 1925 | InvokedViaFunctionUrl: true, |
| 1926 | }), |
| 1927 | ], |
| 1928 | { concurrency: "unbounded" }, |
| 1929 | ); |
| 1930 | }); |
| 1931 | |
| 1932 | const createOrUpdateFunctionUrl = Effect.fn(function* ({ |
| 1933 | functionName, |
| 1934 | url, |
| 1935 | oldUrl, |
| 1936 | currentFunctionUrl, |
| 1937 | }: { |
| 1938 | functionName: string; |
| 1939 | url: FunctionProps["functionUrl"]; |
| 1940 | oldUrl?: FunctionProps["functionUrl"]; |
| 1941 | currentFunctionUrl?: string; |
| 1942 | }) { |
| 1943 | const desired = normalizeFunctionUrl(url); |
| 1944 | const previous = normalizeFunctionUrl(oldUrl); |
| 1945 | const hadFunctionUrl = previous !== undefined || !!currentFunctionUrl; |
| 1946 | |
| 1947 | if (desired) { |
| 1948 | yield* Effect.logDebug( |
| 1949 | `creating function url config ${functionName}`, |
| 1950 | ); |
| 1951 | const shouldClearCors = |
| 1952 | desired.cors === undefined && previous?.cors !== undefined; |
| 1953 | const config = { |
| 1954 | FunctionName: functionName, |
| 1955 | AuthType: desired.authType, |
| 1956 | Cors: desired.cors ?? (shouldClearCors ? {} : undefined), |
| 1957 | InvokeMode: desired.invokeMode, |
| 1958 | } satisfies |
| 1959 | | Lambda.CreateFunctionUrlConfigRequest |
| 1960 | | Lambda.UpdateFunctionUrlConfigRequest; |
| 1961 | const { FunctionUrl } = yield* Lambda.createFunctionUrlConfig( |
| 1962 | config, |
| 1963 | ).pipe( |
| 1964 | Effect.catchTag("ResourceConflictException", () => |
| 1965 | Lambda.updateFunctionUrlConfig(config), |
| 1966 | ), |
| 1967 | retryFunctionMutation, |
| 1968 | ); |
| 1969 | |
| 1970 | if (desired.authType === "NONE") { |
| 1971 | yield* upsertPublicFunctionUrlPermissions(functionName); |
| 1972 | } else { |
| 1973 | yield* removePublicFunctionUrlPermissions(functionName); |
| 1974 | } |
| 1975 | |
| 1976 | yield* Effect.logDebug(`created function url config ${functionName}`); |
| 1977 | return FunctionUrl; |
| 1978 | } else if (hadFunctionUrl) { |
| 1979 | yield* Effect.logDebug( |
| 1980 | `deleting function url config ${functionName}`, |
| 1981 | ); |
| 1982 | yield* Effect.all([ |
| 1983 | Lambda.deleteFunctionUrlConfig({ |
| 1984 | FunctionName: functionName, |
| 1985 | }).pipe( |
| 1986 | retryFunctionMutation, |
| 1987 | Effect.catchTag("ResourceNotFoundException", () => Effect.void), |
| 1988 | ), |
| 1989 | removePublicFunctionUrlPermissions(functionName), |
| 1990 | ]); |
| 1991 | yield* Effect.logDebug(`deleted function url config ${functionName}`); |
| 1992 | } |
| 1993 | return undefined; |
| 1994 | }); |
| 1995 | |
| 1996 | const summary = (code: FunctionDeploymentCode) => |
| 1997 | code.packageType === "Image" |
| 1998 | ? code.imageUri |
| 1999 | : code.archive.length >= 1024 * 1024 |
| 2000 | ? `${(code.archive.length / (1024 * 1024)).toFixed(2)}MB` |
| 2001 | : code.archive.length >= 1024 |
| 2002 | ? `${(code.archive.length / 1024).toFixed(2)}KB` |
| 2003 | : `${code.archive.length}B`; |
| 2004 | |
| 2005 | const refreshPersistedFunctionCode = ({ |
| 2006 | output, |
| 2007 | packageType, |
| 2008 | observed, |
| 2009 | }: { |
| 2010 | output: Function["Attributes"] | undefined; |
| 2011 | packageType: Lambda.PackageType | undefined; |
| 2012 | observed: |
| 2013 | | { |
| 2014 | ImageUri?: string; |
| 2015 | ResolvedImageUri?: string; |
| 2016 | } |
| 2017 | | undefined; |
| 2018 | }): Function["Attributes"]["code"] => { |
| 2019 | if (output?.code === undefined) { |
| 2020 | // Cold adoption has no persisted source hash. Return a complete |
| 2021 | // Attributes shape and let the forced post-adoption reconcile write |
| 2022 | // the desired code and replace this placeholder. |
| 2023 | return { hash: "" }; |
| 2024 | } |
| 2025 | if (packageType !== "Image" || output.code.image === undefined) { |
| 2026 | return output.code; |
| 2027 | } |
| 2028 | |
| 2029 | // Lambda can refresh the deployed URI and digest, but it cannot |
| 2030 | // reconstruct Alchemy's source hash or prove repository ownership. |
| 2031 | // Preserve that metadata from state instead of inferring a managed |
| 2032 | // repository from an arbitrary image during adoption. |
| 2033 | const resolvedImageUri = observed?.ResolvedImageUri; |
| 2034 | const digest = resolvedImageUri?.split("@").at(-1); |
| 2035 | return { |
| 2036 | ...output.code, |
| 2037 | image: { |
| 2038 | ...output.code.image, |
| 2039 | ...(observed?.ImageUri === undefined |
| 2040 | ? {} |
| 2041 | : { imageUri: observed.ImageUri }), |
| 2042 | ...(resolvedImageUri === undefined ? {} : { resolvedImageUri }), |
| 2043 | ...(digest === undefined ? {} : { digest }), |
| 2044 | }, |
| 2045 | }; |
| 2046 | }; |
| 2047 | |
| 2048 | return { |
| 2049 | stables: ["functionArn", "functionName", "roleName"], |
| 2050 | diff: Effect.fn(function* ({ id, olds, news, output }) { |
| 2051 | if (!isResolved(news)) return; |
| 2052 | yield* validateFunctionPackageProps(id, news); |
| 2053 | if (isFunctionImageProps(news)) { |
| 2054 | yield* decodeFunctionImageSource(id, news.image); |
| 2055 | } |
| 2056 | // If output is undefined (resource in creating state), defer to default diff |
| 2057 | if (!output) { |
| 2058 | return undefined; |
| 2059 | } |
| 2060 | // Auto-generated names are engine-owned: the deployed name stays |
| 2061 | // authoritative even if the generator would name this id |
| 2062 | // differently today. Only an explicit user-provided functionName |
| 2063 | // can force a replace. |
| 2064 | const newFunctionName = news.functionName ?? output.functionName; |
| 2065 | if (output.functionName !== newFunctionName) { |
| 2066 | // The new physical name differs from the deployed name, so it can |
| 2067 | // be created before dependents switch over and the old function is |
| 2068 | // deleted. delete-first is only needed by replacements that reuse |
| 2069 | // the same explicit name below. |
| 2070 | return { action: "replace" }; |
| 2071 | } |
| 2072 | if (isFunctionImageProps(olds) !== isFunctionImageProps(news)) { |
| 2073 | return { |
| 2074 | action: "replace", |
| 2075 | deleteFirst: news.functionName !== undefined, |
| 2076 | }; |
| 2077 | } |
| 2078 | if ( |
| 2079 | !isFunctionImageProps(olds) && |
| 2080 | !isFunctionImageProps(news) && |
| 2081 | !!olds.durableConfig !== !!news.durableConfig |
| 2082 | ) { |
| 2083 | // DurableConfig can only be enabled/disabled at CreateFunction — |
| 2084 | // switching a logical id between Function and DurableFunction (or |
| 2085 | // vice versa) must replace the physical function. |
| 2086 | return { |
| 2087 | action: "replace", |
| 2088 | deleteFirst: news.functionName !== undefined, |
| 2089 | }; |
| 2090 | } |
| 2091 | if ( |
| 2092 | !deepEqual( |
| 2093 | normalizeFunctionUrl(olds.functionUrl), |
| 2094 | normalizeFunctionUrl(news.functionUrl), |
| 2095 | ) |
| 2096 | ) { |
| 2097 | return { action: "update" }; |
| 2098 | } |
| 2099 | let imageIdentity: FunctionImageIdentity | undefined; |
| 2100 | let codeHash: string; |
| 2101 | if (isFunctionImageProps(news)) { |
| 2102 | imageIdentity = yield* functionImage.identity( |
| 2103 | id, |
| 2104 | news.image, |
| 2105 | news.architecture, |
| 2106 | ); |
| 2107 | codeHash = imageIdentity.hash; |
| 2108 | } else { |
| 2109 | codeHash = (yield* bundleCode(id, news)).identityHash; |
| 2110 | } |
| 2111 | if (output.code.hash !== codeHash) { |
| 2112 | // code changed |
| 2113 | return { action: "update" }; |
| 2114 | } |
| 2115 | if (isFunctionImageProps(news)) { |
| 2116 | const image = output.code.image; |
| 2117 | if ( |
| 2118 | image === undefined || |
| 2119 | image.source !== imageIdentity?.source || |
| 2120 | (imageIdentity?.source === "uri" |
| 2121 | ? image.imageUri !== imageIdentity.imageUri || |
| 2122 | image.resolvedImageUri !== imageIdentity.resolvedImageUri |
| 2123 | : image.imageUri !== `${image.repositoryUri}:${codeHash}` || |
| 2124 | !(yield* functionImage.isReady( |
| 2125 | id, |
| 2126 | image.repositoryName, |
| 2127 | codeHash, |
| 2128 | ))) |
| 2129 | ) { |
| 2130 | return { action: "update" }; |
| 2131 | } |
| 2132 | if ( |
| 2133 | isFunctionImageProps(olds) && |
| 2134 | !deepEqual( |
| 2135 | toLambdaImageConfig(olds.image), |
| 2136 | toLambdaImageConfig(news.image), |
| 2137 | ) |
| 2138 | ) { |
| 2139 | return { action: "update" }; |
| 2140 | } |
| 2141 | } |
| 2142 | if ( |
| 2143 | toTimeoutSeconds(olds.timeout) !== toTimeoutSeconds(news.timeout) |
| 2144 | ) { |
| 2145 | return { action: "update" }; |
| 2146 | } |
| 2147 | if (olds.architecture !== news.architecture) { |
| 2148 | return { action: "update" }; |
| 2149 | } |
| 2150 | if ( |
| 2151 | olds.reservedConcurrentExecutions !== |
| 2152 | news.reservedConcurrentExecutions |
| 2153 | ) { |
| 2154 | return { action: "update" }; |
| 2155 | } |
| 2156 | // `layers` accepts a LayerVersion resource or a raw ARN, and the |
| 2157 | // two forms are structurally different props even when they name |
| 2158 | // the same layer. Compare them normalized so switching between the |
| 2159 | // forms isn't a phantom change; everything else still falls through |
| 2160 | // to the engine's default props comparison. |
| 2161 | const normalizeLayers = (props: FunctionProps) => ({ |
| 2162 | ...props, |
| 2163 | layers: (props.layers ?? []).map(layerVersionArnOf), |
| 2164 | }); |
| 2165 | if (!havePropsChanged(normalizeLayers(olds), normalizeLayers(news))) { |
| 2166 | return { action: "noop" }; |
| 2167 | } |
| 2168 | }), |
| 2169 | read: Effect.fn(function* ({ id, olds, output }) { |
| 2170 | const functionName = |
| 2171 | output?.functionName ?? |
| 2172 | (yield* createFunctionName(id, olds?.functionName)); |
| 2173 | yield* Effect.logDebug(`reading function ${functionName}`); |
| 2174 | const result = yield* Lambda.getFunction({ |
| 2175 | FunctionName: functionName, |
| 2176 | }).pipe( |
| 2177 | Effect.catchTag("ResourceNotFoundException", () => |
| 2178 | Effect.succeed(undefined), |
| 2179 | ), |
| 2180 | ); |
| 2181 | const fn = result?.Configuration; |
| 2182 | if (!fn?.FunctionArn || !fn.FunctionName || !fn.Role) { |
| 2183 | return undefined; |
| 2184 | } |
| 2185 | const tagsResult = yield* Lambda.listTags({ |
| 2186 | Resource: fn.FunctionArn, |
| 2187 | }).pipe( |
| 2188 | Effect.map((r) => r.Tags ?? {}), |
| 2189 | Effect.catchTag("ResourceNotFoundException", () => |
| 2190 | Effect.succeed({} as Record<string, string>), |
| 2191 | ), |
| 2192 | ); |
| 2193 | const functionUrl = yield* Lambda.getFunctionUrlConfig({ |
| 2194 | FunctionName: fn.FunctionName, |
| 2195 | }).pipe( |
| 2196 | Effect.map((f) => f.FunctionUrl), |
| 2197 | Effect.retry({ |
| 2198 | while: (e: any) => e._tag === "ResourceConflictException", |
| 2199 | schedule: Schedule.exponential(100), |
| 2200 | }), |
| 2201 | Effect.catchTag("ResourceNotFoundException", () => |
| 2202 | Effect.succeed(undefined), |
| 2203 | ), |
| 2204 | ); |
| 2205 | const reservedConcurrentExecutions = |
| 2206 | yield* getReservedConcurrentExecutions(fn.FunctionName); |
| 2207 | // Reuse the persisted output where we have it (e.g. code hash) so |
| 2208 | // diff doesn't see drift it can't reconstruct from the API. |
| 2209 | const attrs = { |
| 2210 | ...output, |
| 2211 | functionArn: fn.FunctionArn, |
| 2212 | functionName: fn.FunctionName, |
| 2213 | functionUrl, |
| 2214 | roleArn: fn.Role, |
| 2215 | roleName: output?.roleName ?? fn.Role.split("/").pop()!, |
| 2216 | code: refreshPersistedFunctionCode({ |
| 2217 | output, |
| 2218 | packageType: fn.PackageType, |
| 2219 | observed: result?.Code, |
| 2220 | }), |
| 2221 | reservedConcurrentExecutions, |
| 2222 | } satisfies Function["Attributes"]; |
| 2223 | return (yield* hasAlchemyTags(id, tagsResult)) |
| 2224 | ? attrs |
| 2225 | : Unowned(attrs); |
| 2226 | }), |
| 2227 | // Account/region collection: exhaustively paginate `listFunctions` |
| 2228 | // (its `Functions` items are `FunctionConfiguration`s, the same shape |
| 2229 | // `read` pulls from `getFunction().Configuration`), then hydrate each |
| 2230 | // into the exact `read` Attributes shape with a bounded fan-out. The |
| 2231 | // code hash is not recoverable from the API (it lives in persisted |
| 2232 | // state), so it is left empty — `delete`/nuke only needs the |
| 2233 | // function/role identifiers. |
| 2234 | list: () => |
| 2235 | Effect.gen(function* () { |
| 2236 | const configs = yield* Lambda.listFunctions.items({}).pipe( |
| 2237 | Stream.runCollect, |
| 2238 | Effect.map((chunk) => Array.from(chunk)), |
| 2239 | ); |
| 2240 | const rows = yield* Effect.forEach( |
| 2241 | configs, |
| 2242 | (fn) => |
| 2243 | Effect.gen(function* () { |
| 2244 | if (!fn.FunctionArn || !fn.FunctionName || !fn.Role) { |
| 2245 | return undefined; |
| 2246 | } |
| 2247 | const functionUrl = yield* Lambda.getFunctionUrlConfig({ |
| 2248 | FunctionName: fn.FunctionName, |
| 2249 | }).pipe( |
| 2250 | Effect.map((f) => f.FunctionUrl), |
| 2251 | // No URL config (or the function vanished between the |
| 2252 | // list and the hydrate) — surface `undefined`, matching |
| 2253 | // `read`. |
| 2254 | Effect.catchTag("ResourceNotFoundException", () => |
| 2255 | Effect.succeed(undefined), |
| 2256 | ), |
| 2257 | ); |
| 2258 | const reservedConcurrentExecutions = |
| 2259 | yield* getReservedConcurrentExecutions(fn.FunctionName); |
| 2260 | return { |
| 2261 | functionArn: fn.FunctionArn, |
| 2262 | functionName: fn.FunctionName, |
| 2263 | functionUrl, |
| 2264 | roleArn: fn.Role, |
| 2265 | roleName: fn.Role.split("/").pop()!, |
| 2266 | code: { hash: "" }, |
| 2267 | ...(reservedConcurrentExecutions === undefined |
| 2268 | ? {} |
| 2269 | : { reservedConcurrentExecutions }), |
| 2270 | } satisfies Function["Attributes"]; |
| 2271 | }), |
| 2272 | { concurrency: 10 }, |
| 2273 | ); |
| 2274 | return rows.filter( |
| 2275 | (row): row is Function["Attributes"] => row !== undefined, |
| 2276 | ); |
| 2277 | }), |
| 2278 | |
| 2279 | precreate: Effect.fn(function* ({ id, news, session }) { |
| 2280 | yield* validateFunctionPackageProps(id, news); |
| 2281 | if (isFunctionImageProps(news)) { |
| 2282 | // Resolve and validate the image before creating the execution |
| 2283 | // role. External references perform only ECR reads here; local |
| 2284 | // sources only hash their build inputs. A bad URI, wrong region, |
| 2285 | // inaccessible image, or invalid build source therefore cannot |
| 2286 | // strand an IAM role when pre-create fails before persisting state. |
| 2287 | yield* functionImage.identity(id, news.image, news.architecture); |
| 2288 | } |
| 2289 | const { accountId, region } = yield* AWSEnvironment.current; |
| 2290 | const { roleName, functionName, roleArn } = yield* createNames( |
| 2291 | id, |
| 2292 | news.functionName, |
| 2293 | ); |
| 2294 | |
| 2295 | const role = yield* createRoleIfNotExists({ |
| 2296 | id, |
| 2297 | roleName, |
| 2298 | vpc: news.vpc, |
| 2299 | }); |
| 2300 | |
| 2301 | const prepared = yield* preparePrecreatedFunctionCode({ |
| 2302 | id, |
| 2303 | props: news, |
| 2304 | session, |
| 2305 | }); |
| 2306 | |
| 2307 | yield* createOrUpdateFunction({ |
| 2308 | id, |
| 2309 | news, |
| 2310 | roleArn: role.Role.Arn, |
| 2311 | code: prepared.deployment, |
| 2312 | functionName, |
| 2313 | env: alchemyEnv, |
| 2314 | session, |
| 2315 | }); |
| 2316 | |
| 2317 | return { |
| 2318 | functionArn: `arn:aws:lambda:${region}:${accountId}:function:${functionName}`, |
| 2319 | functionName, |
| 2320 | functionUrl: undefined, |
| 2321 | roleName, |
| 2322 | code: prepared.attributes, |
| 2323 | roleArn, |
| 2324 | }; |
| 2325 | }), |
| 2326 | reconcile: Effect.fn(function* ({ |
| 2327 | id, |
| 2328 | news, |
| 2329 | olds, |
| 2330 | bindings, |
| 2331 | output, |
| 2332 | session, |
| 2333 | }) { |
| 2334 | yield* validateFunctionPackageProps(id, news); |
| 2335 | if (isFunctionImageProps(news)) { |
| 2336 | yield* decodeFunctionImageSource(id, news.image); |
| 2337 | } |
| 2338 | const generated = yield* createNames(id, news.functionName); |
| 2339 | // Prefer the deployed identifiers: regenerating would target |
| 2340 | // different physical resources if the generator's output for this |
| 2341 | // id ever drifts. (An explicit functionName change arrives here as |
| 2342 | // a fresh replacement instance with no output.) |
| 2343 | const functionName = output?.functionName ?? generated.functionName; |
| 2344 | const roleName = output?.roleName ?? generated.roleName; |
| 2345 | const functionArn = output?.functionArn ?? generated.functionArn; |
| 2346 | const policyName = generated.policyName; |
| 2347 | |
| 2348 | // State is only a cache: the execution role may have been removed |
| 2349 | // out-of-band (or by a previously interrupted cleanup) while the |
| 2350 | // function output remains persisted. Always observe/ensure the |
| 2351 | // deterministic role before syncing binding policies so reconcile |
| 2352 | // can recover from that partial state instead of failing the first |
| 2353 | // `putRolePolicy` with `NoSuchEntityException`. |
| 2354 | const ensuredRole = yield* createRoleIfNotExists({ |
| 2355 | id, |
| 2356 | roleName, |
| 2357 | vpc: news.vpc, |
| 2358 | }); |
| 2359 | const roleArn = ensuredRole.Role.Arn ?? output?.roleArn; |
| 2360 | |
| 2361 | const { |
| 2362 | env, |
| 2363 | vpc: bindingVpc, |
| 2364 | fileSystemConfigs: bindingFileSystemConfigs, |
| 2365 | } = yield* attachBindings({ |
| 2366 | roleName, |
| 2367 | policyName, |
| 2368 | functionArn, |
| 2369 | functionName, |
| 2370 | bindings, |
| 2371 | }); |
| 2372 | |
| 2373 | // Both VPC paths (the `vpc` prop and binding-channel requests) |
| 2374 | // converge on one Lambda VPC config: set-union of subnets and |
| 2375 | // security groups. |
| 2376 | const vpc = |
| 2377 | news.vpc || bindingVpc |
| 2378 | ? { |
| 2379 | subnetIds: [ |
| 2380 | ...new Set([ |
| 2381 | ...(news.vpc?.subnetIds ?? []), |
| 2382 | ...(bindingVpc?.subnetIds ?? []), |
| 2383 | ]), |
| 2384 | ], |
| 2385 | securityGroupIds: [ |
| 2386 | ...new Set([ |
| 2387 | ...(news.vpc?.securityGroupIds ?? []), |
| 2388 | ...(bindingVpc?.securityGroupIds ?? []), |
| 2389 | ]), |
| 2390 | ], |
| 2391 | } |
| 2392 | : undefined; |
| 2393 | |
| 2394 | // The role may predate the VPC request (precreate stub, or a |
| 2395 | // binding newly asking for attachment) — the ENI permissions must |
| 2396 | // be on the role before the function config references the VPC. |
| 2397 | // Attaching is idempotent. |
| 2398 | if (vpc) { |
| 2399 | yield* iam.attachRolePolicy({ |
| 2400 | RoleName: roleName, |
| 2401 | PolicyArn: |
| 2402 | "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole", |
| 2403 | }); |
| 2404 | } |
| 2405 | |
| 2406 | yield* syncTracingPolicy({ |
| 2407 | roleName, |
| 2408 | tracing: news.tracing, |
| 2409 | // Routine update from Active, or adoption (output without olds) |
| 2410 | // where the prior tracing mode is unknown. |
| 2411 | mayHaveBeenActive: |
| 2412 | olds?.tracing === "Active" || |
| 2413 | (olds === undefined && output !== undefined), |
| 2414 | }); |
| 2415 | |
| 2416 | // Desired EFS mounts: the `fileSystemConfigs` prop (access-point |
| 2417 | // references resolved to ARNs) merged with binding-channel |
| 2418 | // requests (`EFS.Mount`), deduped by mount path. |
| 2419 | const desiredFileSystemConfigs = [ |
| 2420 | ...new Map( |
| 2421 | [ |
| 2422 | ...(news.fileSystemConfigs ?? []).map((c) => { |
| 2423 | const arn = accessPointArnOf(c); |
| 2424 | if (arn === undefined) { |
| 2425 | throw new Error( |
| 2426 | `Function(${id}): fileSystemConfigs entry for ${c.localMountPath} needs an access point — set \`accessPoint\` (an AWS.EFS.AccessPoint resource or its ARN) or \`arn\``, |
| 2427 | ); |
| 2428 | } |
| 2429 | return { Arn: arn, LocalMountPath: c.localMountPath }; |
| 2430 | }), |
| 2431 | ...bindingFileSystemConfigs.map((c) => ({ |
| 2432 | Arn: c.arn, |
| 2433 | LocalMountPath: c.localMountPath, |
| 2434 | })), |
| 2435 | ].map((config) => [config.LocalMountPath, config] as const), |
| 2436 | ).values(), |
| 2437 | ]; |
| 2438 | |
| 2439 | // EFS mounts authenticate the execution role against the access |
| 2440 | // point at sandbox start — grant client access before the function |
| 2441 | // configuration references the file system. Attaching is |
| 2442 | // idempotent; the policy is detached with the rest on delete. |
| 2443 | if (desiredFileSystemConfigs.length > 0) { |
| 2444 | yield* iam.attachRolePolicy({ |
| 2445 | RoleName: roleName, |
| 2446 | PolicyArn: |
| 2447 | "arn:aws:iam::aws:policy/AmazonElasticFileSystemClientReadWriteAccess", |
| 2448 | }); |
| 2449 | } |
| 2450 | |
| 2451 | const prepared = yield* prepareFunctionCode({ |
| 2452 | id, |
| 2453 | props: news, |
| 2454 | previousImage: output?.code?.image, |
| 2455 | session, |
| 2456 | }); |
| 2457 | |
| 2458 | yield* createOrUpdateFunction({ |
| 2459 | id, |
| 2460 | news, |
| 2461 | roleArn, |
| 2462 | code: prepared.deployment, |
| 2463 | env: { |
| 2464 | ...env, |
| 2465 | ...news.env, |
| 2466 | }, |
| 2467 | functionName, |
| 2468 | vpc, |
| 2469 | preferUpdate: output !== undefined, |
| 2470 | // `[]` (when the prop/bindings were removed on a function that |
| 2471 | // previously had mounts) explicitly clears the file-system |
| 2472 | // config; `undefined` when it never had any leaves it untouched. |
| 2473 | fileSystemConfigs: |
| 2474 | desiredFileSystemConfigs.length > 0 |
| 2475 | ? desiredFileSystemConfigs |
| 2476 | : olds?.fileSystemConfigs || output !== undefined |
| 2477 | ? [] |
| 2478 | : undefined, |
| 2479 | session, |
| 2480 | }); |
| 2481 | |
| 2482 | yield* waitForFunctionUpdate( |
| 2483 | functionName, |
| 2484 | session, |
| 2485 | vpc !== undefined, |
| 2486 | ); |
| 2487 | |
| 2488 | const previousImage = output?.code.image; |
| 2489 | const nextImage = |
| 2490 | "image" in prepared.attributes |
| 2491 | ? prepared.attributes.image |
| 2492 | : undefined; |
| 2493 | if ( |
| 2494 | previousImage?.ownsRepository === true && |
| 2495 | previousImage.repositoryUri !== nextImage?.repositoryUri |
| 2496 | ) { |
| 2497 | // Lambda has adopted the new image; the previous managed |
| 2498 | // repository is no longer referenced. |
| 2499 | yield* functionImage.deleteRepository(previousImage.repositoryName); |
| 2500 | } |
| 2501 | |
| 2502 | const reservedConcurrentExecutions = |
| 2503 | yield* syncReservedConcurrentExecutions({ |
| 2504 | functionName, |
| 2505 | reservedConcurrentExecutions: news.reservedConcurrentExecutions, |
| 2506 | }); |
| 2507 | |
| 2508 | yield* syncEventInvokeConfig({ |
| 2509 | functionName, |
| 2510 | config: news.eventInvokeConfig, |
| 2511 | }); |
| 2512 | |
| 2513 | const functionUrl = yield* createOrUpdateFunctionUrl({ |
| 2514 | functionName, |
| 2515 | url: news.functionUrl, |
| 2516 | oldUrl: olds?.functionUrl, |
| 2517 | currentFunctionUrl: output?.functionUrl, |
| 2518 | }); |
| 2519 | |
| 2520 | yield* session.note(summary(prepared.deployment)); |
| 2521 | |
| 2522 | return { |
| 2523 | ...output, |
| 2524 | functionArn, |
| 2525 | functionName, |
| 2526 | functionUrl: functionUrl as any, |
| 2527 | roleName, |
| 2528 | roleArn, |
| 2529 | code: prepared.attributes, |
| 2530 | reservedConcurrentExecutions, |
| 2531 | }; |
| 2532 | }), |
| 2533 | delete: Effect.fn(function* ({ output }) { |
| 2534 | // The role may already be gone (e.g. deleted out-of-band or by a |
| 2535 | // previous partial delete) — treat every step as idempotent. |
| 2536 | yield* iam |
| 2537 | .listRolePolicies({ |
| 2538 | RoleName: output.roleName, |
| 2539 | }) |
| 2540 | .pipe( |
| 2541 | Effect.flatMap((policies) => |
| 2542 | Effect.all( |
| 2543 | (policies.PolicyNames ?? []).map((policyName) => |
| 2544 | iam |
| 2545 | .deleteRolePolicy({ |
| 2546 | RoleName: output.roleName, |
| 2547 | PolicyName: policyName, |
| 2548 | }) |
| 2549 | .pipe( |
| 2550 | Effect.catchTag( |
| 2551 | "NoSuchEntityException", |
| 2552 | () => Effect.void, |
| 2553 | ), |
| 2554 | ), |
| 2555 | ), |
| 2556 | ), |
| 2557 | ), |
| 2558 | Effect.catchTag("NoSuchEntityException", () => Effect.void), |
| 2559 | ); |
| 2560 | |
| 2561 | yield* iam |
| 2562 | .listAttachedRolePolicies({ |
| 2563 | RoleName: output.roleName, |
| 2564 | }) |
| 2565 | .pipe( |
| 2566 | Effect.flatMap((policies) => |
| 2567 | Effect.all( |
| 2568 | (policies.AttachedPolicies ?? []).map((policy) => |
| 2569 | iam |
| 2570 | .detachRolePolicy({ |
| 2571 | RoleName: output.roleName, |
| 2572 | PolicyArn: policy.PolicyArn!, |
| 2573 | }) |
| 2574 | .pipe( |
| 2575 | Effect.catchTag( |
| 2576 | "NoSuchEntityException", |
| 2577 | () => Effect.void, |
| 2578 | ), |
| 2579 | ), |
| 2580 | ), |
| 2581 | ), |
| 2582 | ), |
| 2583 | Effect.catchTag("NoSuchEntityException", () => Effect.void), |
| 2584 | ); |
| 2585 | |
| 2586 | yield* Lambda.deleteFunction({ |
| 2587 | FunctionName: output.functionName, |
| 2588 | }).pipe( |
| 2589 | Effect.catchTag("ResourceNotFoundException", () => Effect.void), |
| 2590 | ); |
| 2591 | |
| 2592 | if (output.code.image?.ownsRepository) { |
| 2593 | // Lambda must release its image before the owned repository is |
| 2594 | // force-deleted. Both operations are idempotent, so an interrupted |
| 2595 | // destroy converges on the next run. |
| 2596 | yield* functionImage.deleteRepository( |
| 2597 | output.code.image.repositoryName, |
| 2598 | ); |
| 2599 | } |
| 2600 | |
| 2601 | // Release the execution role before the auxiliary CloudWatch Logs |
| 2602 | // reap below. A recently invoked function can keep that reap alive |
| 2603 | // for ~40 seconds while Lambda flushes its final log batches; if the |
| 2604 | // process is interrupted during that window, leaving role deletion |
| 2605 | // until afterwards strands the deterministic role. IAM can briefly |
| 2606 | // report the just-detached role as still in use, so retry those |
| 2607 | // eventual-consistency conflicts on a bounded schedule. |
| 2608 | yield* iam.deleteRole({ RoleName: output.roleName }).pipe( |
| 2609 | Effect.catchTag("NoSuchEntityException", () => Effect.void), |
| 2610 | Effect.retry({ |
| 2611 | while: (error) => |
| 2612 | error._tag === "DeleteConflictException" || |
| 2613 | error._tag === "ConcurrentModificationException", |
| 2614 | schedule: Schedule.spaced("2 seconds"), |
| 2615 | times: 10, |
| 2616 | }), |
| 2617 | ); |
| 2618 | |
| 2619 | // CloudWatch Logs is not implemented by the floci emulator. The |
| 2620 | // live reap below (flush watch + observe→delete) would sit on |
| 2621 | // describe/delete timeouts for minutes; emulator log groups die |
| 2622 | // with the container anyway. |
| 2623 | if (yield* AWSEnvironment.isLocalEmulator) { |
| 2624 | return null as any; |
| 2625 | } |
| 2626 | |
| 2627 | // Lambda auto-creates /aws/lambda/{name} on the first invoke and |
| 2628 | // deleteFunction does NOT remove it — without this every deleted |
| 2629 | // function leaks an orphaned log group. Worse, the Lambda service |
| 2630 | // flushes the final log batch asynchronously (observed up to ~35s |
| 2631 | // after the last invoke, even after BOTH the function and its role |
| 2632 | // are already deleted), silently re-creating a just-deleted group. |
| 2633 | // The only reliable reap is a bounded watch over that flush window. |
| 2634 | // A provably-quiescent group (last ingestion > 2 minutes ago — |
| 2635 | // every pending flush has long since landed) deletes in a single |
| 2636 | // call, so routine deletes of idle functions stay fast; a group |
| 2637 | // with recent ingestion — or one that does not exist yet, where a |
| 2638 | // first flush may still be in flight — is re-reaped on a short |
| 2639 | // bounded schedule. |
| 2640 | const logGroupName = `/aws/lambda/${output.functionName}`; |
| 2641 | const reapLogGroup = logs.deleteLogGroup({ logGroupName }).pipe( |
| 2642 | Effect.catchTag("ResourceNotFoundException", () => Effect.void), |
| 2643 | // CloudWatch Logs can sit in its SDK retry path for minutes |
| 2644 | // under a full parallel sweep. Log-group cleanup is auxiliary |
| 2645 | // to the already-completed Lambda delete, so bound each reap |
| 2646 | // attempt while preserving the t=0/20/40 flush watch below. |
| 2647 | Effect.timeoutOrElse({ |
| 2648 | duration: "5 seconds", |
| 2649 | orElse: () => |
| 2650 | Effect.logWarning( |
| 2651 | `Timed out reaping Lambda log group ${logGroupName}`, |
| 2652 | ), |
| 2653 | }), |
| 2654 | ); |
| 2655 | const lastIngestion = yield* logs |
| 2656 | .describeLogStreams({ |
| 2657 | logGroupName, |
| 2658 | orderBy: "LastEventTime", |
| 2659 | descending: true, |
| 2660 | limit: 1, |
| 2661 | }) |
| 2662 | .pipe( |
| 2663 | Effect.map((r) => r.logStreams?.[0]?.lastIngestionTime), |
| 2664 | Effect.catchTag("ResourceNotFoundException", () => |
| 2665 | Effect.succeed(undefined), |
| 2666 | ), |
| 2667 | Effect.timeoutOrElse({ |
| 2668 | duration: "5 seconds", |
| 2669 | orElse: () => |
| 2670 | Effect.gen(function* () { |
| 2671 | yield* Effect.logWarning( |
| 2672 | `Timed out inspecting Lambda log group ${logGroupName}`, |
| 2673 | ); |
| 2674 | return undefined; |
| 2675 | }), |
| 2676 | }), |
| 2677 | ); |
| 2678 | const now = yield* Effect.sync(() => Date.now()); |
| 2679 | const quiescent = |
| 2680 | lastIngestion !== undefined && now - lastIngestion > 120_000; |
| 2681 | if (quiescent) { |
| 2682 | yield* reapLogGroup; |
| 2683 | } else { |
| 2684 | // Reaps at t=0s / 20s / 40s — each attempt is idempotent. |
| 2685 | yield* reapLogGroup.pipe( |
| 2686 | Effect.repeat({ |
| 2687 | schedule: Schedule.spaced("20 seconds"), |
| 2688 | times: 2, |
| 2689 | }), |
| 2690 | ); |
| 2691 | } |
| 2692 | |
| 2693 | // A timed-out delete attempt above is not deletion proof, and the |
| 2694 | // Lambda service keeps flushing buffered logs AFTER the function is |
| 2695 | // deleted (typically ~35s, occasionally much longer), silently |
| 2696 | // re-creating a just-deleted group. Converge with a bounded |
| 2697 | // observe→delete loop: any reappearance is simply deleted again |
| 2698 | // (every delete is idempotent — ResourceNotFoundException means |
| 2699 | // done). A recreation we deleted counts as gone; a flush landing |
| 2700 | // after our final check is inherently unobservable and the next |
| 2701 | // nuke census is the backstop. We only fail loudly if the group is |
| 2702 | // still observable at budget exhaustion AND a final delete attempt |
| 2703 | // did not remove it — i.e. the group is genuinely undeletable. |
| 2704 | const describeLogGroup = logs |
| 2705 | .describeLogGroups({ |
| 2706 | logGroupNamePrefix: logGroupName, |
| 2707 | limit: 1, |
| 2708 | }) |
| 2709 | .pipe( |
| 2710 | Effect.map((response) => |
| 2711 | (response.logGroups ?? []).some( |
| 2712 | (group) => group.logGroupName === logGroupName, |
| 2713 | ), |
| 2714 | ), |
| 2715 | ); |
| 2716 | |
| 2717 | // Loop observation: a describe that can't complete in time (API |
| 2718 | // throttling under a busy account / saturated test run) is NOT |
| 2719 | // deletion proof — assume the group is still present and let the |
| 2720 | // bounded loop keep converging instead of dying on a slow read. |
| 2721 | const observeLogGroup = describeLogGroup.pipe( |
| 2722 | Effect.timeoutOrElse({ |
| 2723 | duration: "30 seconds", |
| 2724 | orElse: () => |
| 2725 | Effect.logWarning( |
| 2726 | `Timed out observing Lambda log group ${logGroupName} — assuming still present`, |
| 2727 | ).pipe(Effect.as(true)), |
| 2728 | }), |
| 2729 | ); |
| 2730 | |
| 2731 | // Final authoritative observation: here a timeout must fail loudly, |
| 2732 | // since we are about to declare the delete converged. |
| 2733 | const observeLogGroupOrDie = describeLogGroup.pipe( |
| 2734 | Effect.timeoutOrElse({ |
| 2735 | duration: "30 seconds", |
| 2736 | orElse: () => |
| 2737 | Effect.die( |
| 2738 | new Error( |
| 2739 | `Timed out confirming Lambda log group deletion: ${logGroupName}`, |
| 2740 | ), |
| 2741 | ), |
| 2742 | }), |
| 2743 | ); |
| 2744 | |
| 2745 | const deleteLogGroupAgain = logs |
| 2746 | .deleteLogGroup({ logGroupName }) |
| 2747 | .pipe( |
| 2748 | Effect.retry({ |
| 2749 | while: (error) => |
| 2750 | error._tag === "OperationAbortedException" || |
| 2751 | error._tag === "ServiceUnavailableException", |
| 2752 | schedule: Schedule.max([ |
| 2753 | Schedule.exponential("250 millis"), |
| 2754 | Schedule.recurs(8), |
| 2755 | ]), |
| 2756 | }), |
| 2757 | Effect.catchTag("ResourceNotFoundException", () => Effect.void), |
| 2758 | Effect.timeoutOrElse({ |
| 2759 | duration: "45 seconds", |
| 2760 | orElse: () => |
| 2761 | Effect.die( |
| 2762 | new Error( |
| 2763 | `Timed out deleting Lambda log group ${logGroupName}`, |
| 2764 | ), |
| 2765 | ), |
| 2766 | }), |
| 2767 | ); |
| 2768 | |
| 2769 | const reapIfObserved = Effect.gen(function* () { |
| 2770 | const present = yield* observeLogGroup; |
| 2771 | if (present) { |
| 2772 | yield* deleteLogGroupAgain; |
| 2773 | } |
| 2774 | return present; |
| 2775 | }); |
| 2776 | |
| 2777 | // Happy path (no reappearance): a single describe, done. On a |
| 2778 | // post-delete flush recreation: delete and re-check every 5s for |
| 2779 | // up to ~90s after the last reappearance-free observation. |
| 2780 | const observedAtBudgetEnd = yield* reapIfObserved.pipe( |
| 2781 | Effect.repeat({ |
| 2782 | schedule: Schedule.spaced("5 seconds"), |
| 2783 | until: (present) => !present, |
| 2784 | times: 18, |
| 2785 | }), |
| 2786 | ); |
| 2787 | |
| 2788 | // Budget exhausted with the group still reappearing: the last loop |
| 2789 | // iteration already issued a delete. One final authoritative |
| 2790 | // observation decides — absent means our delete of the latest |
| 2791 | // recreation stuck (gone); present means the group survives its own |
| 2792 | // deletion (denied/undeletable) and must fail loudly. |
| 2793 | if (observedAtBudgetEnd && (yield* observeLogGroupOrDie)) { |
| 2794 | yield* Effect.die( |
| 2795 | new Error( |
| 2796 | `Lambda log group ${logGroupName} remained observable after delete`, |
| 2797 | ), |
| 2798 | ); |
| 2799 | } |
| 2800 | |
| 2801 | return null as any; |
| 2802 | }), |
| 2803 | tail: ({ output }) => { |
| 2804 | const runTailSession = Effect.gen(function* () { |
| 2805 | const { accountId, region } = yield* AWSEnvironment.current; |
| 2806 | |
| 2807 | const logGroupArn = `arn:aws:logs:${region}:${accountId}:log-group:/aws/lambda/${output.functionName}`; |
| 2808 | const response = yield* logs.startLiveTail({ |
| 2809 | logGroupIdentifiers: [logGroupArn], |
| 2810 | }); |
| 2811 | |
| 2812 | if (!response.responseStream) { |
| 2813 | return Stream.empty as Stream.Stream<LogLine>; |
| 2814 | } |
| 2815 | |
| 2816 | return response.responseStream.pipe( |
| 2817 | Stream.flatMap((event) => { |
| 2818 | if ("sessionUpdate" in event && event.sessionUpdate) { |
| 2819 | const lines: LogLine[] = ( |
| 2820 | event.sessionUpdate.sessionResults ?? [] |
| 2821 | ).flatMap((result) => { |
| 2822 | if (!result.message) return []; |
| 2823 | return [ |
| 2824 | { |
| 2825 | timestamp: new Date(result.timestamp ?? Date.now()), |
| 2826 | message: result.message.trimEnd(), |
| 2827 | }, |
| 2828 | ]; |
| 2829 | }); |
| 2830 | return Stream.fromIterable(lines); |
| 2831 | } |
| 2832 | return Stream.empty; |
| 2833 | }), |
| 2834 | ); |
| 2835 | }); |
| 2836 | |
| 2837 | return Stream.unwrap(runTailSession).pipe( |
| 2838 | Stream.retry(Schedule.spaced("1 second")), |
| 2839 | ); |
| 2840 | }, |
| 2841 | logs: ({ |
| 2842 | output, |
| 2843 | options, |
| 2844 | }: { |
| 2845 | output: Function["Attributes"]; |
| 2846 | options: LogsInput; |
| 2847 | }) => |
| 2848 | logs |
| 2849 | .filterLogEvents({ |
| 2850 | logGroupName: `/aws/lambda/${output.functionName}`, |
| 2851 | startTime: options.since?.getTime(), |
| 2852 | limit: options.limit ?? 100, |
| 2853 | }) |
| 2854 | .pipe( |
| 2855 | Effect.map((response) => |
| 2856 | (response.events ?? []).flatMap((event): LogLine[] => { |
| 2857 | if (!event.message) return []; |
| 2858 | return [ |
| 2859 | { |
| 2860 | timestamp: new Date(event.timestamp ?? Date.now()), |
| 2861 | message: event.message.trimEnd(), |
| 2862 | }, |
| 2863 | ]; |
| 2864 | }), |
| 2865 | ), |
| 2866 | Effect.catchTag("ResourceNotFoundException", () => |
| 2867 | Effect.succeed([] as LogLine[]), |
| 2868 | ), |
| 2869 | ), |
| 2870 | }; |
| 2871 | }), |
| 2872 | ); |