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:
- Imports the handler factory from the layer (
@reventlessdev/reventless-aws/...) - Imports
RequestContextfrom the layer (@reventlessdev/reventless-core/...) - Imports user Spec/Behavior modules (bundled via absolute paths or user package specifiers)
- Reads infrastructure details from environment variables (table names, queue URLs)
- Constructs handlers at cold start, dispatches SQS/DynamoDB events to them
Generator Functions
| Function | Component | Trigger |
|---|---|---|
generateAggregateEntryPoint | Aggregate | SQS (CommandTopic) + AppSync (CommandGenerator) |
generateDcbCommandTopicEntryPoint | DCB CommandTopic | SQS + AppSync |
generateReadModelEntryPoint | ReadModel | DynamoDB Stream (EventCollector) |
generateStateViewSliceEntryPoint | StateViewSlice | DynamoDB Stream |
generateAutomationSliceEntryPoint | AutomationSlice | DynamoDB Stream |
generateOutboundTranslationSliceEntryPoint | OutboundTranslationSlice | DynamoDB Stream |
generateExtensionPointEntryPoint | ExtensionPoint | SQS |
generatePluginExtensionPointEntryPoint | PluginExtensionPoint | SQS |
generateAdminEventCollectorEntryPoint | Admin EventCollector | SQS |
generateSideEffectEntryPoint | SideEffectHandler | DynamoDB Stream |
generateTaskBucketEntryPoint | Task | S3 |
generateCounterEntryPoint | Counter | DynamoDB Stream |
generateHeartbeatEntryPoint | Heartbeat | EventBridge Schedule |
generateCommandGeneratorEntryPoint | CommandGenerator | AppSync direct |
generateEventMapperEntryPoint | EventMapper | DynamoDB 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_0andBehavior_0are inlined (absolute paths)@reventlessdev/*,effect/Effect,RequestContextremain as external imports → resolved from layer
Lambda Runtime Integration
Layer ARN
Lambda functions reference the layer via the REVENTLESS_LAYER_ARN environment variable, read at deploy time:
// rescript-pulumi-aws/src/Lambda/Lambda.res
@val external reventlessLayerArn: option<string> = "process.env.REVENTLESS_LAYER_ARN"
RuntimeEnvironment_Lambda.res attaches the layer to every bundled Lambda:
let layers =
Lambda.reventlessLayerArn
->Option.map(arn => [arn->Pulumi.Input.make])
->Option.getOr([])
->Pulumi.Input.make
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.
ESM Module Resolution
Lambda functions use ESM format (index.mjs). Node.js ESM resolution differs from CommonJS:
- CJS (
require): respectsNODE_PATH→ Lambda setsNODE_PATH=/opt/nodejs/node_modules→ layer packages found - ESM (
import): does NOT useNODE_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:
- Clean — delete previous
layer/directory - Install — download
@reventlessdev/reventless-aws@<version>from GitHub Package Registry via Pacote - Resolve tree — use
@npmcli/arboristto compute ideal dependency tree withpreferDedupe: true, production dependencies only - Filter — depth-first traversal, apply inclusion/exclusion rules (see Filtering Rules)
- Extract — copy matching packages to
layer/nodejs/node_modules/ - Post-process — delete unnecessary files from extracted packages (tests, sources, etc.)
- 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: ..., // GitHub Package Registry auth
postProcess: ..., // per-package file cleanup
}
Filtering Rules
Excluded scopes (entire @scope/*):
| Scope | Reason |
|---|---|
@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):
| Category | Packages |
|---|---|
| Build tools | aws-sdk, sury-ppx, esprima, acorn, source-map, source-map-support, cjs-module-lexer |
| Testing | fast-check, pure-rand |
| SSH (Cloner) | ssh2, tweetnacl, bcrypt-pbkdf, asn1 |
| Process spawning | execa, cross-spawn, shebang-command, shebang-regex |
| npm infrastructure | cacache, make-fetch-happen, ssri, minipass-*, socks*, *-proxy-agent, hosted-git-info, npm-*, validate-npm-*, spdx-* |
| Unused at runtime | ramda, lodash, graphql, jsonschema2graphql |
Explicitly included (overrides exclusion):
| Package | Reason |
|---|---|
@rescript/runtime | transitive dep of rescript (excluded as build tool), but required at runtime by all compiled ReScript code |
Filter precedence (DependencyBundler_Filter.res):
- In
includeModules→ include - Marked
dev,optional,devOptional,peer→ exclude - Scope in
excludeScopes→ exclude - Name in
excludeModules→ exclude - All parent dependencies excluded → exclude (
DependentExcluded) - Otherwise → include
Post-Processing
After extraction, per-package cleanup removes files unnecessary at runtime:
| Matcher | Action | Savings |
|---|---|---|
>rescript (any package depending on rescript) | Delete **/*.res, **/*.resi | ~5 MB |
@reventlessdev/reventless-core | Delete coverage/, scripts/, test-helper/, tests/ | — |
@reventlessdev/rescript-effect | Delete tests/ | — |
effect | Delete src/ (TypeScript sources) | ~7.5 MB |
@reventlessdev/rescript-fast-csv, fast-csv | Delete 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 external | Layer builder | Notes |
|---|---|---|
@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) |