1import type * as lambda from "@distilled.cloud/aws/lambda";
2import * as Lambda from "@distilled.cloud/aws/lambda";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6import * as Stream from "effect/Stream";
7import { deepEqual, isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { sha256, sha256Object } from "../../Util/sha256.ts";
12import { zipFiles, type ZipFile } from "../../Util/zip.ts";
13import type { Providers } from "../Providers.ts";
14
15/**
16 * Reference to an S3 bucket: a raw bucket name or anything exposing a
17 * `bucketName` attribute (e.g. an `AWS.S3.Bucket` resource).
18 */
19export type BucketRef = string | { bucketName: string };
20
21/** Resolve a {@link BucketRef} to its bucket name. */
22const bucketNameOf = (bucket: BucketRef): string =>
23 typeof bucket === "string" ? bucket : bucket.bucketName;
24
25/**
26 * Layer content stored in S3. Use this instead of {@link LayerVersionProps.path}
27 * for archives larger than the 50 MB direct-upload limit.
28 */
29export interface LayerVersionS3Content {
30 /**
31 * S3 bucket holding the layer archive — an `AWS.S3.Bucket` resource or a
32 * raw bucket name.
33 */
34 bucket: BucketRef;
35 /** Key of the layer archive object. */
36 key: string;
37 /** Version id of the object, for versioned buckets. */
38 objectVersion?: string;
39}
40
41export interface LayerVersionProps {
42 /**
43 * Name of the layer. If omitted, a unique name is generated.
44 */
45 layerName?: string;
46 /**
47 * Local path to the layer content — either a directory whose contents are
48 * packaged into a zip archive, or an existing `.zip` file uploaded as-is.
49 *
50 * Layer archives are extracted into `/opt` at runtime, so the directory's
51 * layout must match the runtime's convention (e.g. `nodejs/node_modules`
52 * for Node.js dependencies, `bin/` for executables). File modes are
53 * preserved, so executables keep their `+x` bit.
54 *
55 * Exactly one of `path` or {@link s3} is required.
56 */
57 path?: string;
58 /**
59 * Layer content already uploaded to S3. Required for archives above the
60 * 50 MB direct-upload limit.
61 *
62 * Exactly one of {@link path} or `s3` is required.
63 */
64 s3?: LayerVersionS3Content;
65 /**
66 * Description of this version of the layer.
67 */
68 description?: string;
69 /**
70 * Runtimes the layer is compatible with. Used to filter the layer in the
71 * console; Lambda does not enforce it.
72 */
73 compatibleRuntimes?: lambda.Runtime[];
74 /**
75 * Instruction set architectures the layer is compatible with.
76 */
77 compatibleArchitectures?: lambda.Architecture[];
78 /**
79 * The layer's software license — an SPDX identifier, a URL, or the full
80 * license text.
81 */
82 licenseInfo?: string;
83}
84
85export interface LayerVersion extends Resource<
86 "AWS.Lambda.LayerVersion",
87 LayerVersionProps,
88 {
89 /**
90 * Name of the layer.
91 */
92 layerName: string;
93 /**
94 * ARN of the layer (without a version suffix).
95 */
96 layerArn: string;
97 /**
98 * ARN of this specific layer version. This is what
99 * {@link FunctionProps.layers} takes.
100 */
101 layerVersionArn: string;
102 /**
103 * Version number assigned by Lambda.
104 */
105 version: number;
106 /**
107 * Hash of the packaged content and publish configuration. Any change to
108 * it publishes a new version, since layer versions are immutable.
109 */
110 sourceHash: string;
111 /**
112 * Base64-encoded SHA-256 of the layer archive, as reported by Lambda.
113 */
114 codeSha256?: string;
115 /**
116 * Size of the layer archive in bytes.
117 */
118 codeSize?: number;
119 /**
120 * Date the version was created, in ISO-8601 format.
121 */
122 createdDate?: string;
123 /**
124 * Description of the version.
125 */
126 description?: string;
127 /**
128 * Runtimes the layer is compatible with.
129 */
130 compatibleRuntimes?: lambda.Runtime[];
131 /**
132 * Architectures the layer is compatible with.
133 */
134 compatibleArchitectures?: lambda.Architecture[];
135 /**
136 * The layer's software license.
137 */
138 licenseInfo?: string;
139 },
140 never,
141 Providers
142> {}
143
144/**
145 * A version of a Lambda layer — a zip archive of libraries, a custom runtime,
146 * or other dependencies that Lambda extracts into `/opt` alongside your
147 * function code.
148 *
149 * Layer versions are immutable, so changing the content or any publish
150 * setting publishes a new version under the same layer and retires the one
151 * it supersedes. `layerVersionArn` and `version` therefore change on update;
152 * `layerName` and `layerArn` stay put.
153 *
154 * ### Publishing a Layer
155 * **Example:** Package a Local Directory
156 * ```typescript
157 * // ./layers/deps contains nodejs/node_modules/...
158 * const deps = yield* LayerVersion("Deps", {
159 * path: "./layers/deps",
160 * compatibleRuntimes: ["nodejs22.x"],
161 * });
162 * ```
163 *
164 * **Example:** Publish an Existing Archive
165 * ```typescript
166 * const layer = yield* LayerVersion("Ffmpeg", {
167 * path: "./dist/ffmpeg-layer.zip",
168 * description: "static ffmpeg build",
169 * compatibleArchitectures: ["arm64"],
170 * });
171 * ```
172 *
173 * **Example:** Publish From S3
174 * ```typescript
175 * const layer = yield* LayerVersion("BigLayer", {
176 * s3: {
177 * bucket,
178 * key: "layers/big-layer.zip",
179 * },
180 * });
181 * ```
182 *
183 * ### Attaching to a Function
184 * **Example:** Use a Layer in a Function
185 * ```typescript
186 * const fn = yield* Function("Handler", {
187 * main: import.meta.resolve("./handler.ts"),
188 * layers: [deps],
189 * });
190 * ```
191 *
192 * @resource
193 */
194export const LayerVersion = Resource<LayerVersion>("AWS.Lambda.LayerVersion");
195
196/**
197 * Lambda rejects a `PublishLayerVersion` request whose inline archive exceeds
198 * 50 MB — larger archives must go through S3.
199 */
200const MAX_INLINE_ARCHIVE_SIZE = 50 * 1024 * 1024;
201
202export const LayerVersionProvider = () =>
203 Provider.effect(
204 LayerVersion,
205 Effect.gen(function* () {
206 const createLayerName = (id: string, layerName?: string) =>
207 layerName
208 ? Effect.succeed(layerName)
209 : createPhysicalName({
210 id,
211 // Layer names are limited to 140 characters.
212 maxLength: 140,
213 delimiter: "-",
214 });
215
216 /**
217 * Read `directory` recursively into deterministic zip entries, keeping
218 * each file's unix mode so executables stay executable under `/opt`.
219 */
220 const readDirectoryEntries = Effect.fn(function* (directory: string) {
221 const fs = yield* FileSystem.FileSystem;
222 const path = yield* Path.Path;
223 const names = yield* fs.readDirectory(directory, { recursive: true });
224 const entries = yield* Effect.forEach(
225 names,
226 Effect.fn(function* (name) {
227 const file = path.join(directory, name);
228 const info = yield* fs.stat(file);
229 if (info.type !== "File") return [];
230 return [
231 {
232 // Zip archives always use forward slashes.
233 path: name.replaceAll("\\", "/"),
234 content: yield* fs.readFile(file),
235 mode: info.mode,
236 } satisfies ZipFile,
237 ];
238 }),
239 { concurrency: 16 },
240 );
241 return entries.flat();
242 });
243
244 /**
245 * Resolve the props' content into a `PublishLayerVersion` payload plus
246 * a hash of everything that would force a new version.
247 */
248 const packageContent = Effect.fn(function* (props: LayerVersionProps) {
249 const config = {
250 description: props.description,
251 compatibleRuntimes: props.compatibleRuntimes,
252 compatibleArchitectures: props.compatibleArchitectures,
253 licenseInfo: props.licenseInfo,
254 };
255 if (props.s3 && props.path) {
256 return yield* Effect.die(
257 "AWS.Lambda.LayerVersion accepts either `path` or `s3`, not both.",
258 );
259 }
260 if (props.s3) {
261 // Hash the resolved coordinates, not the props, so a bucket passed
262 // as a resource and the same bucket passed by name agree.
263 const s3 = {
264 S3Bucket: bucketNameOf(props.s3.bucket),
265 S3Key: props.s3.key,
266 S3ObjectVersion: props.s3.objectVersion,
267 } satisfies lambda.LayerVersionContentInput;
268 return {
269 content: s3,
270 sourceHash: yield* sha256Object({ s3, config }),
271 };
272 }
273 if (!props.path) {
274 return yield* Effect.die(
275 "AWS.Lambda.LayerVersion requires either `path` or `s3`.",
276 );
277 }
278 const fs = yield* FileSystem.FileSystem;
279 const path = yield* Path.Path;
280 const resolved = path.resolve(props.path);
281 const info = yield* fs.stat(resolved);
282 if (info.type !== "Directory" && path.extname(resolved) !== ".zip") {
283 return yield* Effect.die(
284 `AWS.Lambda.LayerVersion \`path\` must be a directory or a .zip archive: ${resolved}`,
285 );
286 }
287 const archive =
288 info.type === "Directory"
289 ? yield* zipFiles(yield* readDirectoryEntries(resolved))
290 : yield* fs.readFile(resolved);
291 if (archive.byteLength > MAX_INLINE_ARCHIVE_SIZE) {
292 return yield* Effect.die(
293 `AWS.Lambda.LayerVersion archive is ${archive.byteLength} bytes, above Lambda's ${MAX_INLINE_ARCHIVE_SIZE} byte direct-upload limit. Upload it to S3 and use \`s3\` instead.`,
294 );
295 }
296 return {
297 content: {
298 ZipFile: new Uint8Array(archive),
299 } satisfies lambda.LayerVersionContentInput,
300 // Hash the archive bytes rather than the source path so the same
301 // content checked out at a different path does not republish.
302 sourceHash: yield* sha256Object({
303 archive: yield* sha256(archive),
304 config,
305 }),
306 };
307 });
308
309 const snapshot = (
310 layerName: string,
311 sourceHash: string,
312 // `listLayerVersions` items carry the same fields as a
313 // `getLayerVersion` response minus `LayerArn`/`Content`, so both
314 // shapes snapshot through here.
315 version: lambda.GetLayerVersionResponse | lambda.LayerVersionsListItem,
316 ): LayerVersion["Attributes"] | undefined => {
317 if (!version.LayerVersionArn || !version.Version) {
318 return undefined;
319 }
320 const layerArn =
321 ("LayerArn" in version ? version.LayerArn : undefined) ??
322 // `arn:…:layer:{name}:{version}` — the layer ARN is the version ARN
323 // without its trailing version segment.
324 version.LayerVersionArn.slice(
325 0,
326 version.LayerVersionArn.lastIndexOf(":"),
327 );
328 const content = "Content" in version ? version.Content : undefined;
329 return {
330 layerName,
331 layerArn,
332 layerVersionArn: version.LayerVersionArn,
333 version: version.Version,
334 sourceHash,
335 codeSha256: content?.CodeSha256,
336 codeSize: content?.CodeSize,
337 createdDate: version.CreatedDate,
338 description: version.Description || undefined,
339 compatibleRuntimes: version.CompatibleRuntimes,
340 compatibleArchitectures: version.CompatibleArchitectures,
341 licenseInfo: version.LicenseInfo,
342 };
343 };
344
345 const getVersion = (layerName: string, version: number) =>
346 Lambda.getLayerVersion({
347 LayerName: layerName,
348 VersionNumber: version,
349 }).pipe(
350 Effect.catchTag("ResourceNotFoundException", () =>
351 Effect.succeed(undefined),
352 ),
353 );
354
355 return {
356 // `layerVersionArn`/`version` deliberately are NOT stable: an update
357 // publishes a new version under the same layer.
358 stables: ["layerName", "layerArn"],
359 // A layer version is immutable, but the *layer* is not: publishing
360 // re-uses the layer name and mints version N+1. So a content or
361 // config change is an UPDATE, not a replacement — a replacement would
362 // mint a fresh instance id, hence a fresh layer name, and every
363 // change would strand a new layer stuck at version 1.
364 diff: Effect.fn(function* ({ olds, news, output }) {
365 if (!isResolved(news)) return;
366 if (!output) return undefined;
367 // Auto-generated names are engine-owned: the published layer's name
368 // stays authoritative even if the generator would name this id
369 // differently today. Only an explicit `layerName` forces a replace.
370 if ((news.layerName ?? output.layerName) !== output.layerName) {
371 return { action: "replace" } as const;
372 }
373 const { sourceHash } = yield* packageContent(news);
374 if (
375 sourceHash !== output.sourceHash ||
376 olds.description !== news.description ||
377 olds.licenseInfo !== news.licenseInfo ||
378 !deepEqual(olds.compatibleRuntimes, news.compatibleRuntimes) ||
379 !deepEqual(
380 olds.compatibleArchitectures,
381 news.compatibleArchitectures,
382 )
383 ) {
384 return { action: "update" } as const;
385 }
386 return { action: "noop" } as const;
387 }),
388 read: Effect.fn(function* ({ output }) {
389 if (!output) return undefined;
390 const version = yield* getVersion(output.layerName, output.version);
391 return version
392 ? snapshot(output.layerName, output.sourceHash, version)
393 : undefined;
394 }),
395 list: () =>
396 Effect.gen(function* () {
397 // Layers carry no tags, so there is nothing to filter ownership
398 // on — enumerate every version of every layer in the ambient
399 // account/region.
400 const layerNames = yield* Lambda.listLayers.items({}).pipe(
401 Stream.runCollect,
402 Effect.map((chunk) =>
403 Array.from(chunk)
404 .map((layer) => layer.LayerName)
405 .filter((name): name is string => name !== undefined),
406 ),
407 );
408 const versions = yield* Effect.forEach(
409 layerNames,
410 (layerName) =>
411 Lambda.listLayerVersions.items({ LayerName: layerName }).pipe(
412 Stream.runCollect,
413 Effect.map((chunk) =>
414 Array.from(chunk).flatMap((version) => {
415 const attrs = snapshot(layerName, "", version);
416 return attrs ? [attrs] : [];
417 }),
418 ),
419 Effect.catchTag("ResourceNotFoundException", () =>
420 Effect.succeed([] as LayerVersion["Attributes"][]),
421 ),
422 ),
423 { concurrency: 10 },
424 );
425 return versions.flat();
426 }),
427 reconcile: Effect.fn(function* ({ id, news, output, session }) {
428 const layerName =
429 output?.layerName ?? (yield* createLayerName(id, news.layerName));
430 const { content, sourceHash } = yield* packageContent(news);
431
432 // Observe: an interrupted apply may have already published the
433 // version recorded in state. Republishing would leak a version, so
434 // reuse it when it still exists and still matches the desired
435 // content.
436 if (
437 output?.version !== undefined &&
438 output.sourceHash === sourceHash
439 ) {
440 const existing = yield* getVersion(layerName, output.version);
441 const attrs = existing && snapshot(layerName, sourceHash, existing);
442 if (attrs) return attrs;
443 }
444
445 const published = yield* Lambda.publishLayerVersion({
446 LayerName: layerName,
447 Content: content,
448 Description: news.description,
449 CompatibleRuntimes: news.compatibleRuntimes,
450 CompatibleArchitectures: news.compatibleArchitectures,
451 LicenseInfo: news.licenseInfo,
452 });
453
454 const attrs = snapshot(layerName, sourceHash, published);
455 if (!attrs) {
456 return yield* Effect.die(
457 `Lambda layer ${layerName} did not return complete attributes.`,
458 );
459 }
460
461 // Retire the version this one supersedes, so an update doesn't
462 // leak a version per deploy. Functions already referencing the old
463 // version keep working — Lambda retains a copy until nothing
464 // refers to it.
465 if (
466 output?.version !== undefined &&
467 output.version !== attrs.version
468 ) {
469 yield* Lambda.deleteLayerVersion({
470 LayerName: layerName,
471 VersionNumber: output.version,
472 }).pipe(
473 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
474 );
475 }
476
477 yield* session.note(`Layer ${attrs.layerName}:${attrs.version}`);
478
479 return attrs;
480 }),
481 delete: Effect.fn(function* ({ output }) {
482 yield* Lambda.deleteLayerVersion({
483 LayerName: output.layerName,
484 VersionNumber: output.version,
485 }).pipe(
486 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
487 );
488 }),
489 };
490 }),
491 );