1import * as logs from "@distilled.cloud/aws/cloudwatch-logs";
2import type { Credentials } from "@distilled.cloud/aws/Credentials";
3import * as iam from "@distilled.cloud/aws/iam";
4import type { CreateFunctionRequest } from "@distilled.cloud/aws/lambda";
5import * as Lambda from "@distilled.cloud/aws/lambda";
6import { Region } from "@distilled.cloud/aws/Region";
7import type * as lambda from "aws-lambda";
8import * as Cause from "effect/Cause";
9import * as Context from "effect/Context";
10import * as Data from "effect/Data";
11import * as Duration from "effect/Duration";
12import * as Effect from "effect/Effect";
13import * as Exit from "effect/Exit";
14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
16import * as Schedule from "effect/Schedule";
17import * as Scope from "effect/Scope";
18import * as Stream from "effect/Stream";
19import type { HttpClient } from "effect/http/HttpClient";
20import type * as rolldown from "rolldown";
21import { Unowned } from "../../AdoptPolicy.ts";
22import type * as Bundle from "../../Bundle/Bundle.ts";
23import type { PackageInstall } from "../../Bundle/InstalledPackages.ts";
24import { deepEqual, havePropsChanged, isResolved } from "../../Diff.ts";
25import { isScopeEjected, type HttpEffect } from "../../Http.ts";
26import * as Output from "../../Output.ts";
27import { createPhysicalName } from "../../PhysicalName.ts";
28import { Platform, type Main, type PlatformProps } from "../../Platform.ts";
29import type { LogLine, LogsInput } from "../../Provider.ts";
30import * as Provider from "../../Provider.ts";
31import { Resource, type ResourceBinding } from "../../Resource.ts";
32import { packEnvValue, unpackEnvValue } from "../../RuntimeContext.ts";
33import * as Serverless from "../../Serverless/index.ts";
34import { buildEventTelemetry } from "../../Telemetry.ts";
35import { Stack } from "../../Stack.ts";
36import { Stage } from "../../Stage.ts";
37import {
38 createInternalTags,
39 createTagsList,
40 hasAlchemyTags,
41 hasTags,
42} from "../../Tags.ts";
43import { sha256 } from "../../Util/sha256.ts";
44import { zipCode } from "../../Util/zip.ts";
45import { Assets } from "../Assets.ts";
46import { AWSEnvironment } from "../Environment.ts";
47import * as IAM from "../IAM/index.ts";
48import type { PolicyStatement } from "../IAM/Policy.ts";
49import type { Providers } from "../Providers.ts";
50import {
51 syncEventInvokeConfig,
52 type EventInvokeConfig,
53} from "./EventInvokeConfig.ts";
54import { makeFunctionBundler } from "./FunctionBundle.ts";
55import {
56 decodeFunctionImageSource,
57 type FunctionImageAttributes,
58 type FunctionImageIdentity,
59 type FunctionImageSource,
60 makeFunctionImage,
61} from "./FunctionImage.ts";
62import { makeFunctionHttpHandler } from "./HttpServer.ts";
63
64export type { FunctionImageSource } from "./FunctionImage.ts";
65
66export const FunctionTypeId = "AWS.Lambda.Function" as const;
67export type FunctionTypeId = typeof FunctionTypeId;
68
69class 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
81class 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
90export class HandlerContext extends Context.Service<
91 HandlerContext,
92 lambda.Context
93>()("AWS.Lambda.HandlerContext") {}
94
95export 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 */
119export 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
132export 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
152export 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 */
159export 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 */
173export 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 */
180export type LayerRef = string | { layerVersionArn: string };
181
182/** Resolve a {@link LayerRef} to its layer version ARN. */
183export 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 */
190const 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
201export 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
219export 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
307export 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
361export 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
385export type FunctionProps = FunctionZipProps | FunctionImageProps;
386
387const isFunctionImageProps = (
388 props: FunctionProps,
389): props is FunctionImageProps => props.image !== undefined;
390
391interface 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 */
407export 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 */
442const 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 */
461const 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 */
474const 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 */
501export 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
523export 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
566export type FunctionServices = Credentials | Region | AWSEnvironment;
567
568export type FunctionShape = Main<FunctionServices>;
569
570export interface NormalizedFunctionUrlConfig {
571 authType: Lambda.FunctionUrlAuthType;
572 cors?: Lambda.Cors;
573 invokeMode: Lambda.InvokeMode;
574}
575
576export 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 */
999export 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
1121export 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 );