@j0nathan-ll0yd/core
The core package provides handler factory functions, the withObservability() utility, decorators, response helpers, and shared types for Lambda development.
Handler Functions
defineEventBridgeHandler()
Create a fully-observable EventBridge Lambda handler with typed detail-type extraction.
import { defineEventBridgeHandler } from "@j0nathan-ll0yd/core";
function defineEventBridgeHandler<TDetailType extends string, TDetail = unknown>(options: {
detailTypes: TDetailType[]; // Detail types this handler responds to
source?: string; // Event source for filtering (informational)
operationName?: string; // Metrics/tracing name (default: handler function name or 'EventBridgeHandler')
timeout?: number; // Lambda timeout in seconds
memorySize?: number; // Lambda memory in MB
reservedConcurrency?: number; // Reserved concurrent executions
ephemeralStorage?: number; // Ephemeral storage in MB
deadLetterQueue?: boolean | { targetArn?: string };
retryAttempts?: number; // 0-2
}): (
handler: (params: EventBridgeHandlerParams<TDetailType, TDetail>) => Promise<EventBridgeResult>,
) => (
event: EventBridgeEvent<TDetailType, TDetail>,
context: Context,
) => Promise<EventBridgeResult>;Usage:
import { defineEventBridgeHandler } from "@j0nathan-ll0yd/core";
const eb = defineEventBridgeHandler({
detailTypes: ["ExportHealthData"],
timeout: 60,
operationName: "ExportHealthData",
});
export const handler = eb(async ({ detail, detailType, source, context }) => {
const data = await HealthQueries.getTodayData();
await exportToS3("health-export.json", data);
return { processed: 1, detailType };
});Built-in behaviour:
- X-Ray annotations for
detailTypeandeventSource - Structured log entry with detail type and source
- Full observability via
withObservability()
The annotations and the start log are emitted before the detail schema runs. A schema rejection throws out of the parse, and with them after it a failing invocation left no detailType annotation and no start line — the one trace an operator needs to see which event shape was rejected.
Dual triggers and SCHEDULED_EVENT_DETAIL_TYPE. A handler that declares both detailTypes and a schedule receives two event shapes on one function: the subscribed detail types, and CloudWatch's scheduled fire, whose detail-type is the string exported as SCHEDULED_EVENT_DETAIL_TYPE ('Scheduled Event') and whose detail is the rule's input ({} by default).
Neither schema form is applied to a scheduled fire. Its payload is not a subscribed payload, so parsing it against the subscribed schema is a category error — and the workaround it used to force was weakening that schema with .partial(), which gave up validation on the real events too. Keep the schema strict; the framework skips the scheduled shape for you.
When a schedule is present the params widen to a discriminated union on detailType, so narrowing gives the right detail type in each branch:
import { defineEventBridgeHandler, SCHEDULED_EVENT_DETAIL_TYPE } from "@j0nathan-ll0yd/core";
const eb = defineEventBridgeHandler({
detailTypes: ["BroadcastUpdate"],
detailSchema: broadcastUpdateSchema, // strict; never sees a scheduled fire
schedule: { expression: "rate(30 minutes)" },
});
export const handler = eb(async ({ detailType, detail }) => {
if (detailType === SCHEDULED_EVENT_DETAIL_TYPE) {
return compose({ trigger: "schedule" }); // `detail` is `unknown` here
}
return compose({ trigger: detail.reason }); // parsed by broadcastUpdateSchema
});A handler that deliberately SUBSCRIBES to the literal detail type 'Scheduled Event' — listing it in detailTypes — still has its schema applied to it. That is a subscription, not the schedule trigger.
defineScheduledHandler()
Create a fully-observable scheduled Lambda handler for CloudWatch cron/rate events.
import { defineScheduledHandler } from "@j0nathan-ll0yd/core";
function defineScheduledHandler(options: {
operationName?: string; // Metrics/tracing name (default: handler function name or 'ScheduledHandler')
schedule?: {
expression: string; // 'rate(6 hours)' | 'cron(0 12 * * ? *)'
enabled?: boolean; // Default: true
};
timeout?: number; // Lambda timeout in seconds
memorySize?: number; // Lambda memory in MB
reservedConcurrency?: number; // Reserved concurrent executions
ephemeralStorage?: number; // Ephemeral storage in MB
deadLetterQueue?: boolean | { targetArn?: string };
retryAttempts?: number; // 0-2
}): (
handler: (params: ScheduledHandlerParams) => Promise<ScheduledResult>,
) => (event: ScheduledEvent, context: Context) => Promise<ScheduledResult>;Usage:
import { defineScheduledHandler } from "@j0nathan-ll0yd/core";
const scheduled = defineScheduledHandler({
schedule: { expression: "rate(6 hours)" },
timeout: 300,
operationName: "DailyCleanup",
});
export const handler = scheduled(async ({ event, context }) => {
const deleted = await cleanupExpiredRecords();
return { deleted, processed: deleted };
});defineSqsHandler()
Create a fully-observable SQS Lambda handler. The factory iterates the batch, parses each body as JSON (disable with parseBody: false), isolates per-record errors, and builds the SQSBatchResponse partial-batch-failure payload.
import { defineSqsHandler } from '@j0nathan-ll0yd/core'
function defineSqsHandler<TBody = unknown>(
options: DefineSqsHandlerOptions<TBody>
): (handler: (record: SqsRecordContext<TBody>, metadata: WrapperMetadata) => Promise<void>) => BrandedLambda<...>Usage:
const sqs = defineSqsHandler<{ fileId: string }>({
queueName: "downloads",
operationName: "ProcessDownload",
});
export const handler = sqs(async ({ body, messageId }) => {
await processFile(body.fileId);
});A record that throws is added to batchItemFailures by messageId; the rest of the batch still succeeds.
defineS3Handler()
Create a fully-observable S3 Lambda handler. The factory iterates records, URL-decodes each object key, and isolates per-record errors. Set trigger: 'eventbridge' for S3 to EventBridge to Lambda wiring; the default 'direct' handles S3 to Lambda notifications.
import { defineS3Handler } from '@j0nathan-ll0yd/core'
function defineS3Handler(
options: DefineS3HandlerOptions
): (handler: (record: S3RecordContext, metadata: WrapperMetadata) => Promise<void>) => BrandedLambda<...>Usage:
const s3 = defineS3Handler({ bucketName: "uploads", operationName: "IndexUpload" });
export const handler = s3(async ({ bucket, key, size }) => {
await indexObject(bucket, key, size);
});defineWebSocketHandler()
Create a fully-observable WebSocket API Gateway handler. The factory extracts connectionId, routeKey, and the parsed body from the event. Route keys are $connect, $disconnect, and $default.
import { defineWebSocketHandler } from "@j0nathan-ll0yd/core";
function defineWebSocketHandler(
options: DefineWebSocketHandlerOptions,
): (
handler: (params: WebSocketHandlerParams) => Promise<WebSocketResult>,
) => (event: APIGatewayProxyWebsocketEventV2, context: Context) => Promise<WebSocketResult>;Usage:
const ws = defineWebSocketHandler({ routeKey: "$connect" });
export const handler = ws(async ({ connectionId, routeKey }) => {
await storeConnection(connectionId);
return { statusCode: 200 };
});defineAuthorizerHandler()
Create a fully-observable API Gateway custom authorizer. Two overloads: type: 'token' receives a TokenAuthorizerHandlerParams, type: 'request' receives a RequestAuthorizerHandlerParams.
import { defineAuthorizerHandler } from "@j0nathan-ll0yd/core";
const auth = defineAuthorizerHandler({ type: "token", operationName: "ApiAuthorizer" });
export const handler = auth(async ({ authorizationToken, methodArn }) => {
const user = await verify(authorizationToken);
return buildAuthorizerResponse(user.id, "Allow", methodArn, { userId: user.id });
});buildAuthorizerResponse()
Build the IAM policy document an authorizer must return.
function buildAuthorizerResponse(
principalId: string,
effect: "Allow" | "Deny",
resource: string,
context?: APIGatewayAuthorizerResultContext,
): APIGatewayAuthorizerResult;Pass '*' as resource to authorize every method on the API. Values in context reach downstream handlers via event.requestContext.authorizer.
withObservability()
Internal utility that wraps any handler with full observability. This is used internally by all define*Handler functions -- you do not need to call it directly.
function withObservability<TEvent, TResult>(
options: { operationName: string },
handler: (event: TEvent, context: Context, metadata: WrapperMetadata) => Promise<TResult>,
): (event: TEvent, context: Context) => Promise<TResult>;Provides:
- Cold start detection (
ColdStartmetric) - Logger context injection (operation name, Lambda context)
- Correlation ID extraction and propagation
- X-Ray tracing span per invocation
<OperationName>Attemptand<OperationName>SuccessCloudWatch metrics- Automatic
metrics.publishStoredMetrics()infinallyblock
Decorators
Stage 3 decorators (no experimentalDecorators).
@RequiresDatabase(tables)
Class decorator that declares database table permissions for IAM policy extraction. Used on entity query classes or handler-adjacent code.
@RequiresDatabase([{ table: 'users', actions: ['read', 'write'] }])
class UserQueries { ... }@RequiresServices(services)
Class decorator that declares AWS service permissions for IAM policy extraction.
@RequiresServices([{ service: 'sqs', actions: ['sendMessage'], resource: '*' }])
class NotificationService { ... }@Traced(spanName?)
Method decorator that wraps a method with an OpenTelemetry span. Used on entity query methods and service functions -- not on handler functions (handlers get tracing automatically via withObservability()).
class UserQueries {
@Traced('getUserById')
static async getUserById(id: string) { ... }
}@LogMetrics(options?)
Method decorator that publishes CloudWatch metrics. Used internally -- handler functions get metrics automatically.
@InjectContext()
Method decorator that injects Lambda context into the Powertools logger. Used internally -- handler functions get context injection automatically.
getDatabasePermissions(target)
Extract database permission declarations from a decorated class (used by CLI for IAM policy generation).
getServicePermissions(target)
Extract service permission declarations from a decorated class.
Response Helpers
buildValidatedResponse(context, statusCode, body, schema)
Build a response and validate the body against a Zod schema before sending. This is the recommended response builder for all API handlers.
import { buildValidatedResponse } from "@j0nathan-ll0yd/core";
import { z } from "@j0nathan-ll0yd/validation";
const ResponseSchema = z.object({ items: z.array(z.object({ id: z.string() })) });
return buildValidatedResponse(context, 200, { items }, ResponseSchema);buildResponse(context, statusCode, body?, headers?)
Build a standard API Gateway response with correlation headers. Use when response validation is not needed.
return buildResponse(context, 204);
return buildResponse(context, 200, { message: "ok" });buildErrorResponse(context, error, metadata?, requestInfo?)
Build an error response from a thrown error. Maps error types to HTTP status codes automatically. Used internally by defineApiHandler.
sanitizeErrorMessage(message)
Strip sensitive information from error messages before sending to clients.
getErrorMessage(error)
Safely extract an error message string from an unknown error value.
Types
WrapperMetadata
Metadata passed to all wrapped handlers for distributed tracing.
interface WrapperMetadata {
traceId: string; // AWS request ID for this Lambda invocation
correlationId: string; // Correlation ID for end-to-end request tracing
}ApiHandlerParams
Parameters passed to API Gateway handlers without body validation.
interface ApiHandlerParams<TEvent = APIGatewayProxyEvent> {
event: TEvent;
context: Context;
metadata: WrapperMetadata;
}ValidatedApiParams<TBody>
Parameters passed to API Gateway handlers with body validation.
interface ValidatedApiParams<TBody, TEvent = APIGatewayProxyEvent> {
event: TEvent;
context: Context;
metadata: WrapperMetadata;
body: TBody;
}EventBridgeHandlerParams<TDetailType, TDetail>
Parameters passed to EventBridge handler functions.
interface EventBridgeHandlerParams<TDetailType extends string, TDetail> {
event: EventBridgeEvent<TDetailType, TDetail>;
context: Context;
metadata: WrapperMetadata;
detailType: TDetailType;
detail: TDetail;
source: string;
}ScheduledHandlerParams
Parameters passed to scheduled handler functions.
interface ScheduledHandlerParams {
event: ScheduledEvent;
context: Context;
metadata: WrapperMetadata;
}EventBridgeResult
interface EventBridgeResult {
processed?: number;
detailType?: string;
[key: string]: unknown;
}ScheduledResult
interface ScheduledResult {
processed?: number;
deleted?: number;
[key: string]: unknown;
}Configuration
defineConfig(config)
Type-safe project configuration for mantle.config.ts.
import { defineConfig } from "@j0nathan-ll0yd/core";
export default defineConfig({
name: "my-app",
database: { provider: "aurora-dsql" },
eventbridge: { bus: "my-app-events" },
storage: [
{
name: "uploads",
cloudfront: true,
corsOrigins: ["https://example.com"],
},
],
});MantleConfig
interface MantleConfig {
name: string;
database?: { provider: "aurora-dsql" | "aurora-serverless-v2" | "neon" };
auth?: { provider: string };
features?: { observability?: boolean; resilience?: boolean };
storage?: StorageBucketConfig[];
eventbridge?: EventBridgeConfig;
}EventBridgeConfig
interface EventBridgeConfig {
bus: string;
}StorageBucketConfig
interface StorageBucketConfig {
name: string;
cloudfront?: boolean;
cloudfrontPriceClass?: string;
corsOrigins?: string[];
corsMethods?: string[];
responseHeaders?: {
corsOrigins?: string[];
corsMethods?: string[];
maxAgeSec?: number;
};
intelligentTiering?: boolean;
versioning?: boolean;
}defineLambda(config)
Build-time macro for per-Lambda configuration. No-op at runtime -- the CLI extracts config via ts-morph AST analysis.
For EventBridge and scheduled handlers, LambdaConfig properties are passed directly as options to defineEventBridgeHandler and defineScheduledHandler. defineLambda() is only needed for standalone handlers that have no corresponding define*Handler call.
import { defineLambda } from "@j0nathan-ll0yd/core";
// Only needed for standalone handlers
defineLambda({
timeout: 60,
memorySize: 256,
deadLetterQueue: true,
retryAttempts: 2,
});LambdaConfig
interface LambdaConfig {
timeout?: number;
memorySize?: number; // Default: 128
reservedConcurrency?: number;
ephemeralStorage?: number; // Default: 512
schedule?: {
expression: string; // 'rate(6 hours)' | 'cron(0 12 * * ? *)'
enabled?: boolean; // Default: true
};
eventbridge?: {
detailTypes: string[];
};
deadLetterQueue?:
| boolean
| {
targetArn?: string;
};
retryAttempts?: number; // 0-2
}Utility Functions
extractCorrelationId(event, context)
Extract or generate a correlation ID from any event type (API Gateway, SQS, EventBridge, S3, Scheduled).
appendCorrelationToLogger(correlationId, traceId)
Append correlation and trace IDs to the Powertools logger context.
validateStaticBearerToken(event, expectedToken)
Validate a static bearer token from an API Gateway event's Authorization header. Throws UnauthorizedError if the token is missing or does not match.
getHeader(event, name)
Read a request header case-insensitively from an API Gateway v1 or v2 event. Returns undefined when absent. HTTP/2 lowercases header names, so never index event.headers directly.
const contentType = getHeader(event, "Content-Type");getHeaderRequired(event, name)
Same lookup, but throws ValidationError when the header is absent.
const signature = getHeaderRequired(event, "X-Hub-Signature-256");getStaticAsset(key)
Resolve a static asset that the CLI wired into the Lambda environment from storage[].assets. Reads the ASSET_<KEY>_KEY and ASSET_<KEY>_URL environment variables and throws when either is missing.
const logo = getStaticAsset("logo.png");
logo.key; // 'assets/logo.png'
logo.url; // 'https://cdn.example.com/assets/logo.png'assertNever(value, message?)
Exhaustiveness check for discriminated unions. Always throws; the compiler rejects the call when a case is unhandled.
switch (status) {
case "active":
return handleActive();
case "inactive":
return handleInactive();
default:
return assertNever(status);
}Event Emission
Emit EventBridge events through these helpers rather than the raw putEvents API. They read EVENT_BUS_NAME and EVENT_SOURCE once per cold start and, by default, log and swallow failures so event emission never breaks the primary handler path.
emitEvent(options)
Publish a single typed event.
import { emitEvent } from "@j0nathan-ll0yd/core";
await emitEvent({ detailType: "UserCreated", detail: { userId }, metadata });Options: detailType (required), detail, metadata, busName, source, suppressErrors (default true). Passing metadata from the handler params propagates the correlation ID as _correlationId inside the event detail.
emitEvents(events, batchOptions?)
Publish many events in one putEvents call. Each entry may override busName and source; batchOptions supplies the shared defaults.
await emitEvents(
[
{ detailType: "UserCreated", detail: { userId } },
{ detailType: "AuditLogged", detail: { action: "signup" } },
],
{ metadata },
);createBoundEmitEvent(metadata)
Return emitEvent and emitEvents pre-bound to a handler's metadata, so correlation propagation cannot be forgotten at a call site. Every define*Handler factory passes the bound emitters to the handler.
const { emitEvent, emitEvents } = createBoundEmitEvent(metadata);
await emitEvent({ detailType: "UserCreated", detail: { userId } });Result Type
A lightweight Result<T, E> for operations whose failure is expected and should not throw.
type Result<T, E = Error> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: E };| Function | Purpose |
|---|---|
ok(value) | Build a success result. |
err(error) | Build a failure result. |
isOk(result) | Type guard narrowing to the success branch. |
isErr(result) | Type guard narrowing to the failure branch. |
unwrap(result) | Return the value, or throw the contained error. |
mapResult(result, fn) | Apply fn to the value of a success result, passing failures through. |
const parsed = parseInput(raw);
if (isErr(parsed)) return buildErrorResponse(context, parsed.error);
const upper = mapResult(parsed, (value) => value.toUpperCase());
return buildResponse(context, 200, unwrap(upper));Branded Types
@j0nathan-ll0yd/core re-exports the framework type vocabulary from @j0nathan-ll0yd/types so handlers need one import: ApiGatewayRouteKey, BearerToken, CloudFrontDistributionId, DsqlClusterArn, EventBusName, LambdaArn, S3BucketName, and SqsQueueUrl. Wrap at the environment-read site, not at each call site.
const bucket = S3BucketName(getRequiredEnv("DATA_BUCKET"));Deployment Provenance
Fail-closed loaders for the deployment facts mantle deploy binds. See Deployment Provenance for the end-to-end flow, and exportToS3 for the writer that stamps them.
| Function | Returns | Requires |
|---|---|---|
loadDeploymentProvenance() | DeploymentProvenance | ENVIRONMENT, SOURCE_REVISION, DEPLOYED_AT |
loadSourceRevisionProvenance() | DeploymentProvenance | ENVIRONMENT, SOURCE_REVISION |
loadSourceRevision() | string | SOURCE_REVISION |
Each throws ValidationError when a required variable is absent, blank, or malformed. A Lambda deployed outside the provenance-aware path fails before it can publish an untraceable artifact.
loadSourceRevisionProvenance() is the honest subset: use it for a writer whose artifact no contract rule describes, so it claims the revision and nothing more.
import { loadDeploymentProvenance } from "@j0nathan-ll0yd/core";
import { exportToS3 } from "@j0nathan-ll0yd/aws";
const provenance = loadDeploymentProvenance();
await exportToS3({ bucket, key: "llms.txt.json", data, provenance });PROVENANCE_ENV_VARS
Maps each DeploymentProvenance field to the environment variable it is bound from. The CLI generator reads this map, so the wire names have one definition.
const PROVENANCE_ENV_VARS = {
sourceRevision: "SOURCE_REVISION",
deployedAt: "DEPLOYED_AT",
workflowRef: "DEPLOYMENT_WORKFLOW_REF",
workflowRunId: "DEPLOYMENT_WORKFLOW_RUN_ID",
workflowRunAttempt: "DEPLOYMENT_WORKFLOW_RUN_ATTEMPT",
} as const;Constants
UserStatus
Authentication state of the caller on an API handler's context.
const UserStatus = { Authenticated: "Authenticated", Anonymous: "Anonymous" } as const;Test Seams
Exported for tests only. They reset module-level caches that would otherwise leak between test cases. Never call them from handler or service code.
| Function | Resets |
|---|---|
_resetCorsCache() | The parsed CORS configuration cached by the response helpers. |
_setRequestOrigin(origin) | The request origin the response helpers echo back in CORS headers. |
_resetEmitEventCache() | The cached EVENT_BUS_NAME and EVENT_SOURCE values. |