Alpha Version: You are viewing the ALPHA documentation. This is an experimental version and may contain breaking changes.
Skip to main content

AWS Lambda Layer

The reventless-aws Lambda layer provides shared runtime dependencies to all Lambda functions deployed by the framework. Instead of bundling the entire framework into every function, esbuild marks framework packages as external and the Lambda layer supplies them at runtime under /opt/nodejs/node_modules/.

Architecture Overview​

┌─────────────────────────────────────────────────────────┐
│ Lambda Function (/var/task/) │
│ │
│ index.mjs (esbuild bundle) │
│ ├── user domain code (Spec, Behavior) ← bundled │
│ ├── import ... from "effect/Effect" ← external │
│ ├── import ... from "@reventlessdev/..." ← external │
│ └── import ... from "@rescript/..." ← external │
│ │
├─────────────────────────────────────────────────────────┤
│ Lambda Layer (/opt/nodejs/node_modules/) │
│ │
│ @reventlessdev/reventless-aws/ │
│ @reventlessdev/reventless-core/ │
│ @reventlessdev/rescript-effect/ │
│ @rescript/runtime/ │
│ effect/ │
│ sury/ │
│ uuid/ │
│ hash-object/ │
│ ...transitive runtime dependencies │
│ │
├─────────────────────────────────────────────────────────┤
│ AWS Lambda Runtime │
│ @aws-sdk/* (provided by AWS) │
└─────────────────────────────────────────────────────────┘

How Bundling Works​

esbuild Configuration​

Lambda function code is bundled by esbuild at deploy time (Util_Bundle.mjs). The following packages are marked external — they are NOT included in the bundle and must be present in the layer (or the AWS runtime) at execution time:

external: [
"@aws-sdk/*", // provided by AWS Lambda runtime
"effect", // layer-provided
"effect/*", // deep imports from effect
"sury", // layer-provided
"sury/*", // deep imports from sury
"@reventlessdev/*", // framework packages — layer-provided
"@rescript/*", // ReScript runtime — layer-provided
"@standard-schema/*", // layer-provided
"uuid", // layer-provided
"hash-object", // layer-provided
]

This list must stay in sync with what the layer builder produces. If a package is external here but missing from the layer, Lambda functions will crash at import time.

Two Bundling Modes​

bundleHandler(entryPoint, exportName) — wraps a single compiled .res.mjs handler:

import { handleQueueEvent } from "/abs/path/handler.res.mjs";
export const handler = handleQueueEvent;

bundleEntryPoint(entryPointCode) — bundles a generated JavaScript string (used for complex multi-handler Lambdas). The code string is written to a temp file and fed to esbuild.

Both modes produce a {code: AssetArchive, sourceCodeHash: string} tuple for Pulumi.

Package Specifiers vs. Absolute Paths​

This distinction is critical for correct bundling:

  • Package specifiers (e.g., "@reventlessdev/reventless-aws/src/adapter/Runtime/AggregateHandlerFactory.mjs") match the @reventlessdev/* external pattern → esbuild leaves them as-is → resolved from the layer at runtime.

  • Absolute paths (e.g., /Users/.../node_modules/@reventlessdev/.../AggregateHandlerFactory.mjs) do NOT match external patterns → esbuild follows and inlines the file. Any transitive @reventlessdev/* imports inside that file then become top-level externals in the output — but they resolve from /var/task/, not from the layer.

Rule: framework module paths (factoryModule, requestContextModule) must be package specifiers. User domain paths (specModulePath, behaviorModulePath) are resolved to absolute paths so esbuild bundles them.

Deep Imports for effect​

The effect package barrel (import { Effect } from "effect") re-exports everything, including testing utilities that transitively require fast-check. Since fast-check is excluded from the layer, barrel imports crash at runtime.

All code must use deep imports:

// correct — loads only the Effect module
import { Effect } from "effect/Effect";
import { Stream } from "effect/Stream";

// wrong — loads the barrel, pulls in fast-check
import { Effect, Stream } from "effect";

This applies to:

  • ReScript bindings (rescript-effect): use @module("effect/Effect"), not @module("effect")
  • Handler factories (hand-written .mjs): import { Effect } from "effect/Effect"
  • Generated entry points (Util_EntryPoint.mjs): template literals generate deep imports

Entry Point Generation​

Util_EntryPoint.mjs generates Lambda handler code for each component type. The generated code:

  1. Imports the handler factory from the layer (@reventlessdev/reventless-aws/...)
  2. Imports RequestContext from the layer (@reventlessdev/reventless-core/...)
  3. Imports user Spec/Behavior modules (bundled via absolute paths or user package specifiers)
  4. Reads infrastructure details from environment variables (table names, queue URLs)
  5. Constructs handlers at cold start, dispatches SQS/DynamoDB events to them

Generator Functions​

FunctionComponentTrigger
generateAggregateEntryPointAggregateSQS (CommandTopic) + AppSync (CommandGenerator)
generateDcbCommandTopicEntryPointDCB CommandTopicSQS + AppSync
generateReadModelEntryPointReadModelDynamoDB Stream (EventCollector)
generateStateViewSliceEntryPointStateViewSliceDynamoDB Stream
generateAutomationSliceEntryPointAutomationSliceDynamoDB Stream
generateOutboundTranslationSliceEntryPointOutboundTranslationSliceDynamoDB Stream
generateExtensionPointEntryPointExtensionPointSQS
generatePluginExtensionPointEntryPointPluginExtensionPointSQS
generateAdminEventCollectorEntryPointAdmin EventCollectorSQS
generateSideEffectEntryPointSideEffectHandlerDynamoDB Stream
generateTaskBucketEntryPointTaskS3
generateCounterEntryPointCounterDynamoDB Stream
generateHeartbeatEntryPointHeartbeatEventBridge Schedule
generateCommandGeneratorEntryPointCommandGeneratorAppSync direct
generateEventMapperEntryPointEventMapperDynamoDB Stream

Generated Code Pattern​

import { createCommandTopicHandler } from "@reventlessdev/reventless-aws/src/adapter/Runtime/AggregateHandlerFactory.mjs";
import { Effect } from "effect/Effect";
import * as RequestContext from "@reventlessdev/reventless-core/src/RequestContext.res.mjs";
import * as Spec_0 from "/abs/path/to/ItemSpec.res.mjs";
import * as Behavior_0 from "/abs/path/to/ItemBehavior.res.mjs";

const runEffect = (correlationId, effect) =>
effect
.pipe(Effect.provideService(RequestContext.tag, { correlationId: correlationId || "unknown" }))
.pipe(Effect.runPromise);

const commandTopicHandlers = new Map([
[process.env.HANDLER_0_QUEUE_ARN, createCommandTopicHandler({
specModule: Spec_0,
behaviorModule: Behavior_0,
eventLogTableName: process.env.HANDLER_0_TABLE,
queueUrl: process.env.HANDLER_0_QUEUE_URL,
})],
]);

export const handler = async (event, context) => {
// route SQS event to correct handler by queue ARN
// ...
};

After esbuild bundling:

  • Spec_0 and Behavior_0 are inlined (absolute paths)
  • @reventlessdev/*, effect/Effect, RequestContext remain as external imports → resolved from layer

Lambda Runtime Integration​

Layer ARN​

The layer ARN is resolved at deploy time — from REVENTLESS_LAYER_ARN if set, otherwise from the SSM parameter /reventless/layer-arn/{stack} through the AWS CLI:

// rescript-pulumi-aws/src/Lambda/Lambda.res
type layerLookup =
| Found(string)
| NotFound({parameter: string, region: option<string>})
| CouldNotLook({parameter: string, region: option<string>, reason: string})

Every function the framework creates takes its layers from Lambda.reventlessLayers(), which looks once per deploy and throws unless the lookup is Found:

let layers = Lambda.reventlessLayers()

The current layer ARN is stored in AWS SSM Parameter Store at /reventless/layer-arn/{stack} (e.g. /reventless/layer-arn/alpha), written by CI after each layer publish. The deploy workflow reads it back into REVENTLESS_LAYER_ARN. Parameters are regional — CI writes and reads them in the same region as the deploy. It is no longer committed to the repository.

That parameter exists only in the account CI publishes to. In any other account, publish the reventless-layer.zip asset of the matching GitHub release yourself and write the parameter — see Getting Started with AWS. When neither source answers, the deploy stops with a message naming the parameter and the region it looked in — or, when the AWS CLI is missing or the call is refused, saying why it could not look. Deploying on would leave every function failing with Cannot find package on its first invocation, because the archive never carries the framework's own packages.

ESM Module Resolution​

Lambda functions use ESM format (index.mjs). Node.js ESM resolution differs from CommonJS:

  • CJS (require): respects NODE_PATH → Lambda sets NODE_PATH=/opt/nodejs/node_modules → layer packages found
  • ESM (import): does NOT use NODE_PATH → walks ancestor directories from the importing file

AWS Lambda runtimes (Node.js 18+) handle this by making layer packages discoverable to ESM imports. The layer structure at /opt/nodejs/node_modules/ is resolved correctly by the Lambda runtime's module loader.

Layer Builder​

Location​

reventless/layer-builder/ — private package, not published.

How It Works​

The builder (DependencyBundler.res) executes these steps:

  1. Clean — delete previous layer/ directory
  2. Install — download @reventlessdev/reventless-aws@<version> from npmjs via Pacote (anonymously — the packages are public)
  3. Resolve tree — use @npmcli/arborist to compute ideal dependency tree with preferDedupe: true, production dependencies only
  4. Filter — depth-first traversal, apply inclusion/exclusion rules (see Filtering Rules)
  5. Extract — copy matching packages to layer/nodejs/node_modules/
  6. Post-process — delete unnecessary files from extracted packages (tests, sources, etc.)
  7. Zip — create reventless-layer.zip

Configuration (Main.res)​

let config: DependencyBundler_Config.t = {
sourcePackageName: "@reventlessdev/reventless-aws",
sourcePackageVersion, // from REVENTLESS_AWS_VERSION env var
pathToLayerData, // builder/layer/
pathToSavedDependencies, // builder/layer/nodejs/node_modules
excludeScopes: [...],
includeModules: [...],
excludeModules: [...],
registryOpts: ..., // @reventlessdev scope → registry.npmjs.org, no auth
postProcess: ..., // per-package file cleanup
}

Filtering Rules​

Excluded scopes (entire @scope/*):

ScopeReason
@pulumi/*deploy-time infrastructure only
@types/*TypeScript type definitions
@opentelemetry/*optional observability
@aws-sdk/*provided by AWS Lambda runtime
@smithy/*AWS SDK internals (provided at runtime)
@sigstore/*code signing (deploy-time)
@npmcli/*npm CLI tools
@gar/*artifact registry tools

Excluded modules (specific packages):

CategoryPackages
Build toolsaws-sdk, sury-ppx, esprima, acorn, source-map, source-map-support, cjs-module-lexer
Testingfast-check, pure-rand
SSH (Cloner)ssh2, tweetnacl, bcrypt-pbkdf, asn1
Process spawningexeca, cross-spawn, shebang-command, shebang-regex
npm infrastructurecacache, make-fetch-happen, ssri, minipass-*, socks*, *-proxy-agent, hosted-git-info, npm-*, validate-npm-*, spdx-*
Unused at runtimeramda, lodash, graphql, jsonschema2graphql

Explicitly included (overrides exclusion):

PackageReason
@rescript/runtimetransitive dep of rescript (excluded as build tool), but required at runtime by all compiled ReScript code

Filter precedence (DependencyBundler_Filter.res):

  1. In includeModules → include
  2. Marked dev, optional, devOptional, peer → exclude
  3. Scope in excludeScopes → exclude
  4. Name in excludeModules → exclude
  5. All parent dependencies excluded → exclude (DependentExcluded)
  6. Otherwise → include

Post-Processing​

After extraction, per-package cleanup removes files unnecessary at runtime:

MatcherActionSavings
>rescript (any package depending on rescript)Delete **/*.res, **/*.resi~5 MB
@reventlessdev/reventless-coreDelete coverage/, scripts/, test-helper/, tests/—
@reventlessdev/rescript-effectDelete tests/—
effectDelete src/ (TypeScript sources)~7.5 MB
@reventlessdev/rescript-fast-csv, fast-csvDelete tests/, test/, examples/, benchmark/, docs/—

Output​

reventless-layer.zip
└── nodejs/
└── node_modules/
├── @reventlessdev/
│ ├── reventless-aws/
│ ├── reventless-core/
│ ├── reventless-spec/
│ ├── reventless-infra/
│ ├── reventless-interop/
│ ├── rescript-effect/
│ ├── rescript-uuid/
│ ├── rescript-hash-object/
│ ├── rescript-fast-csv/
│ ├── rescript-node/
│ └── rescript-pulumi-pulumi/
├── @rescript/
│ └── runtime/
├── effect/
│ └── dist/ (src/ deleted by post-process)
├── sury/
├── uuid/
├── hash-object/
└── ...transitive deps

Final size: ~30–40 MB uncompressed. Lambda limit is 50 MB per layer (uncompressed).

CI/CD​

Workflow: .github/workflows/build-lambda-layer.yml​

The layer is rebuilt and published by CI whenever the AWS provider package is released, whenever the layer builder itself changes on a release branch, or on demand. The publish job builds the layer, refuses artifacts that have grown past the size budget, publishes a new Lambda layer version, records its ARN so deploys can pick it up, and attaches the archive to the release.

Publishing requires credentials scoped to exactly that: permission to publish and read versions of this one layer, and to write the parameter holding its ARN. Deploy credentials need only to read that parameter back. Keep them separate — the job that publishes a layer has no reason to be able to deploy a stack.

When to Rebuild​

The layer must be rebuilt when:

  • Framework packages (@reventlessdev/*) change in ways that affect runtime code
  • Dependencies are added/removed/updated
  • Handler factory files (.mjs) are modified
  • The layer builder configuration changes

The layer does NOT need rebuilding for:

  • User domain code changes (Spec, Behavior) — these are bundled into function code
  • Deploy-time infrastructure changes (Pulumi resources)
  • Changes to excluded packages (build tools, testing)

Keeping External List and Layer in Sync​

The esbuild external list (Util_Bundle.mjs) and the layer builder configuration (Main.res) must stay synchronized:

esbuild externalLayer builderNotes
@aws-sdk/*excluded (scope)provided by AWS Lambda runtime
effect, effect/*included (dependency of reventless-aws)src/ deleted by post-process
sury, sury/*included (dependency)—
@reventlessdev/*included (source package + deps)tests/scripts deleted
@rescript/*@rescript/runtime explicitly includedrescript itself excluded (build tool)
@standard-schema/*included (dependency of sury)—
uuidincluded (dependency)—
hash-objectincluded (dependency)—

If a package is added to esbuild externals, it must also be present in the layer. If a package is removed from the layer, it must be removed from esbuild externals (or bundled).

Troubleshooting​

"Cannot find package 'X' imported from /var/task/index.mjs"​

Package X is in esbuild's external list but missing from the layer. Either:

  • Add it to the layer builder (ensure it's a dependency of @reventlessdev/reventless-aws or listed in includeModules)
  • Remove it from esbuild's external list (it will be bundled instead)
  • If X is @reventlessdev/*: verify the framework module path is a package specifier, not an absolute path (absolute paths cause esbuild to inline the file but leave its transitive imports as externals)

"Cannot find package 'fast-check'"​

Something is importing from the effect barrel instead of using deep imports. Check:

  • Handler factory files (*HandlerFactory.mjs): must use import { Effect } from "effect/Effect"
  • Generated entry points (Util_EntryPoint.mjs): template literals must generate "effect/Effect"
  • ReScript bindings (rescript-effect): must use @module("effect/Effect"), not @module("effect")

Layer ARN mismatch​

If a deploy uses an old layer ARN:

  • Check REVENTLESS_LAYER_ARN environment variable during pulumi up
  • Verify the SSM parameter has the latest ARN: aws ssm get-parameter --name /reventless/layer-arn/{stack} --query Parameter.Value --output text (in the deploy region)
  • Manual override: set REVENTLESS_LAYER_ARN=arn:aws:lambda:... before deploying, or pass the layer-arn workflow input

Layer size exceeds 40 MB​

  • Review recently added dependencies
  • Add large unused packages to excludeModules in Main.res
  • Add post-process handlers to strip non-essential files
  • Check if new transitive dependencies introduced bulk

Cold start performance​

The layer is extracted once per Lambda container init. Larger layers increase cold start time. Keep the layer lean by:

  • Excluding build/test/documentation files via post-processing
  • Excluding packages unused at runtime
  • Using deep imports to avoid loading unnecessary module trees

File Reference​

FilePurpose
reventless/layer-builder/src/Main.resLayer builder config (exclusions, post-processing)
reventless/layer-builder/src/DependencyBundler.resBuild orchestration
reventless/layer-builder/src/DependencyBundler_Config.resConfig type definition
reventless/layer-builder/src/DependencyBundler_Filter.resInclusion/exclusion logic
reventless/layer-builder/src/DependencyBundler_PostProcess.resPer-package file cleanup
reventless/aws/src/util/Util_Bundle.mjsesbuild bundling (external list)
reventless/aws/src/util/Util_Bundle.resReScript bindings for bundling
reventless/aws/src/util/Util_EntryPoint.mjsLambda entry point code generation
reventless/aws/src/util/Util_EntryPoint.resEntry point config types
reventless/aws/src/adapter/Runtime/RuntimeEnvironment_Lambda.resLambda deployment (layer attachment)
rescript/pulumi-aws/src/Lambda/Lambda.resREVENTLESS_LAYER_ARN binding
.github/workflows/build-lambda-layer.ymlCI/CD layer build + publish
SSM /reventless/layer-arn/{stack}Current layer ARN (written by CI, per stack/region)