Lambda Custom Resources

Writing a Lambda-Backed Custom Resource Handler

infra/seed.yaml declares the table, a role allowing only dynamodb:BatchWriteItem on it, and the function booknest-seeder (nodejs24.x) with Code: seeder/, a directory that aws cloudformation package zips and uploads (Nested Stacks), holding the handler and a copy of db/seed.json. The custom resource passes the table's name, cuts the one-hour timeout to two minutes, and exposes the handler's Count:

infra/seed.yaml (end): the custom resourceYAML
  SeedBooks:
    Type: Custom::BookSeed
    Properties:
      ServiceToken: !GetAtt SeederFunction.Arn
      ServiceTimeout: 120
      TableName: !Ref BooksTable
Outputs:
  SeededCount:
    Value: !GetAtt SeedBooks.Count
infra/seeder/index.mjs: the seeding providerJavaScript
import { readFile } from "node:fs/promises";
import { DynamoDBClient, BatchWriteItemCommand } from "@aws-sdk/client-dynamodb";
import { marshall } from "@aws-sdk/util-dynamodb";
const db = new DynamoDBClient({});
async function respond(event, Status, Data = {}, Reason = "see CloudWatch Logs") {
  const { StackId, RequestId, LogicalResourceId, ResourceProperties } = event;
  const PhysicalResourceId = `seed-${ResourceProperties.TableName}`;
  const body = JSON.stringify({ Status, Reason, PhysicalResourceId, StackId, RequestId,
    LogicalResourceId, Data });
  await fetch(event.ResponseURL, { method: "PUT", body, headers: { "content-type": "" } });
}
export const handler = async (event) => {
  try {
    const { books } = JSON.parse(await readFile("./seed.json", "utf8"));
    const toRequest = event.RequestType === "Delete"
      ? (b) => ({ DeleteRequest: { Key: marshall({ id: b.id }) } })
      : (b) => ({ PutRequest: { Item: marshall(b) } });
    const table = event.ResourceProperties.TableName;
    const RequestItems = { [table]: books.map(toRequest) };
    await db.send(new BatchWriteItemCommand({ RequestItems }));
    await respond(event, "SUCCESS", { Count: books.length });
  } catch (err) {
    await respond(event, "FAILED", {}, `${err.name}: ${err.message}`);
  }
};

The runtime includes the AWS 24 SDK for JavaScript v3, so no node_modules are packaged. One BatchWriteItem writes the books, or removes them on Delete, and respond() is the whole protocol: a JSON body PUT to the ResponseURL, with the empty content type that the presigned URL was signed for. AWS's cfn-response module does the same, but only inline ZipFile code can load it.

Packaging and deploying the seeded tableShell
cd ~/v5-ch7/booknest/infra
aws s3 mb s3://booknest-templates
aws cloudformation package --template-file seed.yaml --s3-bucket booknest-templates \
  --output-template-file seed.packaged.yaml > /dev/null
aws cloudformation deploy --stack-name booknest-seed --template-file seed.packaged.yaml \
  --capabilities CAPABILITY_IAM
aws cloudformation describe-stacks --stack-name booknest-seed --output text \
  --query 'Stacks[0].[StackStatus,Outputs[0].OutputValue]'
Output
make_bucket: booknest-templates
Uploading to d7a7926bb4f4b470957474784b01efa3  1542 / 1542.0  (100.00%)
...
Successfully created/updated stack - booknest-seed
CREATE_COMPLETE   unknown

The stack is CREATE_COMPLETE, yet SeededCount is unknown: the handler never ran. Custom Resources on LocalStack explains why; first, drive the handler the way CloudFormation 24 would.