Skip to content

Definitions

Overview

Every resource in Effortless is created with a define* function. Each call declares what you need — the framework handles the infrastructure.

Some definitions include a Lambda handler (a callback like onRecord, onMessage, or route handlers). Others are resource-only — they create AWS resources without any code attached:

DefinitionCreatesHandler required?
defineApiLambda Function URL (single Lambda, multiple routes)Optional (get / post)
defineTableDynamoDB table + optional stream LambdaNo — table-only when no onRecord/onBatch
defineAppCloudFront + Lambda Function URL serving SSRNo (built-in file server)
defineStaticSiteS3 + CloudFront + optional Lambda@EdgeNo (optional middleware)
defineFifoQueueSQS FIFO + LambdaYes (onMessage/onBatch)
defineScheduleEventBridge + LambdaYes — Planned
defineEventEventBridge + LambdaYes — Planned
defineBucketS3 bucket + optional event LambdaNo — resource-only when no onObjectCreated/onObjectRemoved
defineMailerSES email identityNo — resource-only, used via deps

Resource-only definitions are useful when you need the infrastructure but handle it from elsewhere. For example, a defineTable without stream callbacks creates a DynamoDB table, a defineBucket without event callbacks creates an S3 bucket, and a defineMailer creates an SES email identity — all referenceable via deps:

// Just a table — no Lambda, no stream
export const users = defineTable({
schema: unsafeAs<User>(),
});
// Just a bucket — no Lambda, no event notifications
export const uploads = defineBucket({});
// API that writes to the table and bucket
export const api = defineApi({
basePath: "/users",
deps: () => ({ users, uploads }),
post: async ({ req, deps }) => {
await deps.users.put({
pk: "USER#1", sk: "PROFILE",
data: { tag: "user", name: "Alice", email: "alice@example.com" },
});
await deps.uploads.put("avatars/user-1.png", avatarBuffer);
return { status: 201 };
},
});

Type inference

Every handler function (defineApi, defineTable, defineFifoQueue) uses TypeScript generics internally to connect types across schema, setup, deps, config, and callbacks. You don’t need to specify these generics yourself — TypeScript infers them automatically from the options you pass.

Always use schema to provide the data type. Data in DynamoDB streams, SQS messages, and HTTP request bodies is external input — even if you wrote the producer yourself. Schemas evolve, fields get renamed, old records linger in streams after a migration, and a queue may contain messages sent before your latest deploy. A schema function is the single place that catches these mismatches at runtime instead of letting bad data silently flow through your logic.

For runtime validation (recommended), pass a real validation function — Zod, Effect Schema, or plain TypeScript:

import { z } from "zod";
const Order = z.object({
tag: z.string(),
amount: z.number(),
status: z.enum(["pending", "paid", "shipped"]),
});
export const orders = defineTable({
schema: (input) => Order.parse(input), // validates + infers T = { tag, amount, status }
deps: () => ({ users }),
config: {
threshold: param("threshold", Number),
},
setup: async ({ config }) => ({
db: createPool(config.threshold),
}),
onRecord: async ({ record, ctx, deps, config }) => {
// record.new?.data is z.infer<typeof Order> | undefined
// ctx is { db: Pool }
// deps.users is TableClient<User>
// config.threshold is number
},
});

For prototyping or when you trust the data shape, use unsafeAs<T>() — it provides type inference without runtime validation:

type Order = { tag: string; amount: number; status: string };
export const orders = defineTable({
schema: unsafeAs<Order>(), // T = Order, no runtime check
onRecord: async ({ record }) => {
// record.new?.data is Order | undefined
},
});

Shared options

These options are available on all Lambda-backed handlers (defineApi, defineTable, defineFifoQueue, defineBucket).

schema

Decode/validate function for incoming data (request body, stream record, or queue message). When provided, the handler receives a typed data / record / message.body. If the function throws, the framework returns an error automatically (400 for HTTP, batch item failure for streams/queues).

A real validation function is recommended — data in streams, queues, and HTTP bodies is external input that can change independently from your code:

// With Zod
schema: (input) => OrderSchema.parse(input),
// Plain TypeScript
schema: (input: unknown) => {
const obj = input as any;
if (!obj?.name) throw new Error("name required");
return { name: obj.name as string };
},

For prototyping (no runtime validation), use unsafeAs<T>():

schema: unsafeAs<Order>(),

setup

Factory function called once on cold start. The return value is cached and passed as ctx to every invocation. Supports async. When deps or config are declared, receives them as argument.

// No deps/config — zero-arg
setup: () => ({ pool: createPool() }),
// With deps and/or config
setup: async ({ deps, config }) => ({
pool: createPool(config.dbUrl),
}),

deps

Dependencies on other handlers (tables, buckets, and mailers). The framework auto-wires environment variables, IAM permissions, and injects typed clients at runtime — TableClient<T> for tables, BucketClient for buckets, EmailClient for mailers.

import { orders } from "./orders.js";
import { uploads } from "./uploads.js";
import { mailer } from "./mailer.js";
deps: () => ({ orders, uploads, mailer }),
// → deps.orders is TableClient<Order>
// → deps.uploads is BucketClient
// → deps.mailer is EmailClient

config

SSM Parameter Store values. Declare with param() for transforms, or plain strings for simple keys. Values are fetched once on cold start and cached.

import { param } from "effortless-aws";
config: {
dbUrl: "database-url", // plain string → string value
appConfig: param("app-config", JSON.parse), // param() with transform → parsed type
},
// → config.dbUrl is string
// → config.appConfig is ReturnType<typeof JSON.parse>

SSM path is built automatically: /${project}/${stage}/${key}.

static

Glob patterns for files to bundle into the Lambda ZIP. At runtime, read them via the files callback argument.

static: ["src/templates/*.ejs"],
// → files.read("src/templates/invoice.ejs") returns file contents as string

permissions

Additional IAM permissions for the Lambda execution role. Format: "service:Action".

permissions: ["s3:PutObject", "ses:SendEmail"],

logLevel

Logging verbosity: "error" (errors only), "info" (+ execution summary), "debug" (+ truncated input/output). Default: "info".

onError

Called when a handler callback throws. Receives { error, ...handlerArgs }. For HTTP handlers, should return an HttpResponse. For stream/queue handlers, defaults to console.error.

onError: ({ error, ctx, deps }) => {
console.error("Handler failed:", error);
return { status: 500, body: { error: "Something went wrong" } };
},

onAfterInvoke

Called after each Lambda invocation completes, right before the process freezes. This is the only reliable place to run code between invocations — setInterval and background tasks don’t execute while Lambda is frozen.

Receives the same args as the handler (ctx, deps, config, files — when declared). Supports async. If onAfterInvoke throws, the error is logged but does not affect the handler’s response.

const buffer: LogEntry[] = [];
export default defineApi({
basePath: "/api",
onAfterInvoke: async () => {
// Flush batched logs when buffer is large or stale
if (buffer.length >= 100 || timeSinceLastFlush() > 30_000) {
await flush(buffer);
}
},
get: {
"/users": async ({ req }) => {
buffer.push({ path: req.path, time: Date.now() });
return { status: 200, body: users };
},
},
});

defineApi

Creates: Lambda + Function URL with built-in routing

defineApi is the primary way to build HTTP APIs. It deploys one Lambda with a Function URL that handles all routing internally — no API Gateway needed.

  • GET routes — query handlers keyed by relative path (e.g., "/users/{id}")
  • POST handler — single command entry point with discriminated union schema
  • Shared deps, config, setup, static, onError, onAfterInvoke across all routes
  • Unmatched routes return 404 automatically
export default defineApi({
// Required
basePath: string, // e.g. "/api" — prefix for all routes
// Optional
memory?: number,
timeout?: DurationInput,
permissions?: Permission[],
setup?: ({ deps, config }) => C,
deps?: { [key]: TableHandler },
config?: { [key]: param(...) },
static?: string[],
onError?: (error, req) => HttpResponse,
onAfterInvoke?: ({ ctx, deps, config, files }) => void | Promise<void>,
// GET routes — queries
get?: {
"/path": async ({ req, ctx, deps, config }) => {
// req.params — path parameters extracted from {param} placeholders
return { status: 200, body: { ... } };
},
},
// POST — commands
schema?: (input: unknown) => T, // validate & parse POST body
post?: async ({ req, data, ctx, deps, config }) => {
// data — parsed body (when schema is set)
return { status: 201, body: { ... } };
},
});

Schema validation

export const users = defineApi({
basePath: "/users",
schema: (input) => {
const obj = input as any;
if (!obj?.name) throw new Error("name is required");
return { name: obj.name as string };
},
post: async ({ data }) => {
// data is { name: string } — typed from schema return type
return { status: 201, body: { created: data.name } };
},
});

When schema throws, the framework returns a 400 response automatically with the error message.

Dependencies

import { orders } from "./orders.js";
export const api = defineApi({
basePath: "/orders",
deps: () => ({ orders }),
post: async ({ req, deps }) => {
// deps.orders is TableClient<Order> — typed from the table's generic
await deps.orders.put({
pk: "USER#123", sk: "ORDER#456",
data: { tag: "order", amount: 99, status: "pending" },
});
return { status: 201 };
},
});

Dependencies are auto-wired: the framework sets environment variables, IAM permissions, and provides typed TableClient instances at runtime. See architecture for details.

CQRS with discriminated unions

The schema + post pattern works great with discriminated unions — one POST endpoint handles all commands:

import { defineApi, defineTable, unsafeAs } from "effortless-aws";
import { z } from "zod";
type User = { tag: string; name: string; email: string };
export const users = defineTable({ schema: unsafeAs<User>() });
const Action = z.discriminatedUnion("action", [
z.object({ action: z.literal("create"), name: z.string(), email: z.string() }),
z.object({ action: z.literal("delete"), id: z.string() }),
]);
export default defineApi({
basePath: "/api",
deps: () => ({ users }),
get: {
"/users": async ({ deps }) => ({
status: 200,
body: await deps.users.queryByTag({ tag: "user" }),
}),
"/users/{id}": async ({ req, deps }) => ({
status: 200,
body: await deps.users.get({ pk: `USER#${req.params.id}`, sk: "PROFILE" }),
}),
},
schema: (input) => Action.parse(input),
post: async ({ data, deps }) => {
switch (data.action) {
case "create": {
const id = crypto.randomUUID();
await deps.users.put({ pk: `USER#${id}`, sk: "PROFILE", data: { tag: "user", ...data } });
return { status: 201, body: { id } };
}
case "delete": {
await deps.users.delete({ pk: `USER#${data.id}`, sk: "PROFILE" });
return { status: 200, body: { ok: true } };
}
}
},
});

Built-in best practices:

  • Lambda Function URL — no API Gateway overhead, lower latency, zero cost for the URL itself.
  • Single Lambda — shared cold start, deps, and setup across all routes. One function to deploy and keep warm.
  • Built-in CORS — permissive CORS headers configured automatically on the Function URL.
  • Cold start optimization — the setup factory runs once on cold start and is cached across invocations.
  • Schema validation — when schema is set, the POST body is parsed and validated before your handler runs. Invalid requests get a 400 response automatically.
  • Typed dependenciesdeps provides typed TableClient<T>, BucketClient, and EmailClient instances with auto-wired IAM permissions.
  • Auto-infrastructure — Lambda, Function URL, and IAM permissions are created on deploy.

defineTable

Creates: DynamoDB Table + (optional) Stream + Lambda + Event Source Mapping

Every table uses an opinionated single-table design with a fixed structure:

AttributeTypePurpose
pkStringPartition key
skStringSort key
tagStringEntity type discriminant (auto-extracted from your data)
dataMapYour domain data (typed as T)
ttlNumberOptional TTL (always enabled, set to auto-expire items)

Your domain type T is what goes inside data. The envelope (pk, sk, tag, ttl) is managed by effortless.

export const orders = defineTable({
// Optional — type inference
schema?: (input: unknown) => T, // infers record type T (or use unsafeAs<T>())
// Optional — table
billingMode?: "PAY_PER_REQUEST" | "PROVISIONED", // default: PAY_PER_REQUEST
tagField?: string, // field in data for entity discriminant (default: "tag")
// Optional — stream
streamView?: "NEW_AND_OLD_IMAGES" | "NEW_IMAGE" | "OLD_IMAGE" | "KEYS_ONLY", // default: NEW_AND_OLD_IMAGES
batchSize?: number, // 1-10000, default: 100
startingPosition?: "LATEST" | "TRIM_HORIZON", // default: LATEST
// Optional — lambda
memory?: number,
timeout?: DurationInput,
permissions?: Permission[], // additional IAM permissions
setup?: ({ deps, config }) => C, // factory for shared state (cached on cold start)
deps?: { [key]: TableHandler }, // inter-handler dependencies
config?: { [key]: param(...) }, // SSM parameters
onAfterInvoke?: ({ ctx, deps, config, files }) => void | Promise<void>,
// Stream handler — choose one mode:
// Mode 1: per-record processing
onRecord: async ({ record, table, ctx, deps, config }) => { ... },
onBatchComplete?: async ({ results, failures, table, ctx, deps, config }) => { ... },
// Mode 2: batch processing
onBatch: async ({ records, table, ctx, deps, config }) => { ... },
});

Use schema or unsafeAs<T>() to provide the data type. T is the domain data stored inside the data attribute — not the full DynamoDB item. TypeScript infers all generic parameters from the options object.

import { defineTable, unsafeAs } from "effortless-aws";
type Order = { tag: string; amount: number; status: string };
// Option 1: unsafeAs<T>() — type-only, no runtime validation
export const orders = defineTable({
schema: unsafeAs<Order>(),
});
// Option 2: schema function — with runtime validation
export const orders = defineTable({
schema: (input: unknown) => {
const obj = input as Record<string, unknown>;
if (typeof obj?.amount !== "number") throw new Error("amount required");
return { tag: String(obj.tag), amount: obj.amount, status: String(obj.status) };
},
});

Tag field (tagField)

Every item has a top-level tag attribute in DynamoDB (useful for GSIs and filtering). Effortless auto-extracts it from your data — by default from data.tag. If your discriminant field is named differently, set tagField:

type Order = { type: "order"; amount: number };
export const orders = defineTable({
tagField: "type", // → extracts data.type as the DynamoDB tag attribute
schema: unsafeAs<Order>(),
});

Callback arguments

All stream callbacks (onRecord, onBatch, onBatchComplete) receive:

ArgTypeDescription
record / recordsTableRecord<T> / TableRecord<T>[]Stream records with typed new/old TableItem<T> values
tableTableClient<T>Typed client for this table (auto-injected)
ctxCResult from setup() factory (if provided)
deps{ [key]: TableClient }Typed clients for dependent tables (if deps is set)
configResolveConfig<P>SSM parameter values (if config is set)

Stream records follow the TableItem<T> structure:

record.eventName // "INSERT" | "MODIFY" | "REMOVE"
record.new?.pk // string
record.new?.sk // string
record.new?.tag // string (entity discriminant)
record.new?.data // T (your typed domain data)
record.new?.ttl // number | undefined
record.keys // { pk: string; sk: string }

Per-record processing

export const orders = defineTable({
schema: unsafeAs<Order>(),
onRecord: async ({ record, table }) => {
if (record.eventName === "INSERT" && record.new) {
console.log(`New order: $${record.new.data.amount}`);
}
}
});

Each record is processed individually. If one fails, only that record is retried via PartialBatchResponse.

Batch processing

export const events = defineTable({
schema: unsafeAs<ClickEvent>(),
batchSize: 100,
onBatch: async ({ records }) => {
const inserts = records
.filter(r => r.eventName === "INSERT")
.map(r => r.new!.data);
await bulkIndex(inserts);
}
});

All records in a batch are processed together. If the handler throws, all records are reported as failed.

TableClient

Every table handler receives a table: TableClient<T> — a typed client for its own table. Other handlers get it via deps. T is your domain data type (what goes inside data).

TableClient<T>
put(item: PutInput<T>, options?: { ifNotExists?: boolean }): Promise<void>
get(key: { pk: string; sk: string }): Promise<TableItem<T> | undefined>
delete(key: { pk: string; sk: string }): Promise<void>
update(key: { pk: string; sk: string }, actions: UpdateActions<T>): Promise<void>
query(params: QueryParams): Promise<TableItem<T>[]>
tableName: string

put — writes an item. Tag is auto-extracted from data[tagField]. Use { ifNotExists: true } to prevent overwriting existing items.

await table.put({
pk: "USER#123", sk: "ORDER#456",
data: { tag: "order", amount: 100, status: "new" },
});
// Conditional write — fails if item already exists
await table.put(
{ pk: "USER#123", sk: "ORDER#456", data: { tag: "order", amount: 100, status: "new" } },
{ ifNotExists: true },
);

get / delete — by partition key + sort key:

const item = await table.get({ pk: "USER#123", sk: "ORDER#456" });
// item: { pk, sk, tag, data: Order, ttl? } | undefined
await table.delete({ pk: "USER#123", sk: "ORDER#456" });

update — partial updates without reading the full item. set, append, and remove target fields inside data (effortless auto-prefixes data. in the DynamoDB expression). tag and ttl update top-level attributes.

await table.update({ pk: "USER#123", sk: "ORDER#456" }, {
set: { status: "shipped" }, // SET data.status = "shipped"
append: { tags: ["priority"] }, // Append to data.tags list
remove: ["tempField"], // REMOVE data.tempField
tag: "shipped-order", // Update top-level tag
ttl: 1700000000, // Set TTL (null to remove)
});

query — by partition key with optional sort key conditions:

// All orders for a user
const orders = await table.query({ pk: "USER#123" });
// Orders with sk prefix
const orders = await table.query({ pk: "USER#123", sk: { begins_with: "ORDER#" } });
// Sort key conditions:
sk: "exact-value" // =
sk: { begins_with: "PREFIX" } // begins_with(sk, :v)
sk: { gt: "value" } // sk > :v
sk: { gte: "value" } // sk >= :v
sk: { lt: "value" } // sk < :v
sk: { lte: "value" } // sk <= :v
sk: { between: ["a", "z"] } // sk BETWEEN :v1 AND :v2
// Pagination and ordering
const recent = await table.query({
pk: "USER#123",
sk: { begins_with: "ORDER#" },
limit: 10,
scanIndexForward: false, // newest first
});

Dependencies

import { users } from "./users.js";
export const orders = defineTable({
schema: unsafeAs<Order>(),
deps: () => ({ users }),
onRecord: async ({ record, deps }) => {
const userId = record.new?.data.userId;
if (userId) {
const user = await deps.users.get({ pk: `USER#${userId}`, sk: "PROFILE" });
console.log(`Order by ${user?.data.name}`);
}
}
});

Batch accumulation

export const ordersWithBatch = defineTable({
schema: unsafeAs<Order>(),
onRecord: async ({ record }) => {
return { amount: record.new?.data.amount ?? 0 };
},
onBatchComplete: async ({ results, failures }) => {
const total = results.reduce((sum, r) => sum + r.amount, 0);
console.log(`Batch total: $${total}, failed: ${failures.length}`);
}
});

Resource-only (no Lambda)

// Just creates the DynamoDB table — no stream, no Lambda
export const users = defineTable({
schema: unsafeAs<User>(),
});

Built-in best practices:

  • Single-table design — fixed pk/sk/tag/data/ttl structure. Flexible access patterns via composite keys, no schema migrations needed.
  • Partial batch failures — each record is processed individually. If one fails, only that record is retried via PartialBatchResponse. The rest of the batch succeeds.
  • Typed records — use schema: unsafeAs<Order>() for type inference, or a validation function for runtime checks. schema validates the data portion of stream records.
  • Table self-clienttable arg provides a typed TableClient<T> for the handler’s own table, auto-injected with no config.
  • Smart updatesupdate() auto-prefixes data. for domain fields, so you can do partial updates without reading the full item.
  • Typed dependenciesdeps provides typed TableClient<T> instances for other tables with auto-wired IAM and env vars.
  • Batch accumulationonRecord return values are collected into results for onBatchComplete. Use this for bulk writes, aggregations, or reporting.
  • Auto-TTL — TTL is always enabled on the ttl attribute. Set it on put() or update() and DynamoDB auto-deletes expired items.
  • Conditional writes — use { ifNotExists: true } on put() for idempotent inserts.
  • Cold start optimization — the setup factory runs once and is cached across invocations.
  • Progressive complexity — omit handlers for table-only. Add onRecord for stream processing. Add onBatch for batch mode. Add deps for cross-table access.
  • Auto-infrastructure — DynamoDB table, stream, Lambda, event source mapping, and IAM permissions are all created on deploy from this single definition.

defineApp

Creates: CloudFront distribution + Lambda Function URL + S3 bucket for deploying SSR frameworks.

export const app = defineApp({
// Required
server: string, // directory with Lambda server handler (e.g. ".output/server")
assets: string, // directory with static assets for S3 (e.g. ".output/public")
// Optional
path?: string, // base URL path (default: "/")
build?: string, // shell command to run before deploy
memory?: number, // Lambda memory in MB (default: 1024)
timeout?: number, // Lambda timeout in seconds (default: 30)
permissions?: string[], // additional IAM permissions
domain?: string | Record<string, string>, // custom domain (or stage-keyed)
});

The server directory must contain an index.mjs (or index.js) that exports a handler function — this is the standard output of frameworks like Nuxt (NITRO_PRESET=aws-lambda) and Astro SSR.

Static assets from assets are uploaded to S3 and served via CloudFront with CachingOptimized. All other requests go to the Lambda Function URL with CachingDisabled.

export const app = defineApp({
server: ".output/server",
assets: ".output/public",
build: "nuxt build",
domain: "app.example.com",
});

Built-in best practices:

  • Lambda Function URL — no API Gateway overhead (~20-50ms latency saved), secured with AWS_IAM + CloudFront OAC.
  • Auto-detected cache behaviors — static asset patterns (directories and files in assets) are auto-detected and routed to S3 with immutable caching.
  • CloudFront CDN — global edge distribution for both static assets and SSR responses.
  • Custom domain — string or stage-keyed record ({ prod: "app.example.com" }). ACM certificate in us-east-1 is auto-discovered.
  • Auto-infrastructure — Lambda, Function URL, S3 bucket, CloudFront distribution, OAC, IAM role, and bucket policy are all created on deploy.

For static-only sites (no SSR), use defineStaticSite instead.


defineStaticSite

Creates: S3 bucket + CloudFront distribution + Origin Access Control + CloudFront Function (viewer request) + optional Lambda@Edge (middleware).

export const docs = defineStaticSite({
// Required
dir: string, // directory with built site files
// Optional
index?: string, // default: "index.html"
spa?: boolean, // SPA mode: serve index for all paths (default: false)
build?: string, // shell command to run before deploy
domain?: string, // custom domain (e.g. "example.com")
errorPage?: string, // custom 404 page relative to dir (default: auto-generated)
routes?: { [pattern]: handler }, // path patterns proxied to API Gateway
middleware?: (request) => ..., // Lambda@Edge middleware for auth, redirects, etc.
seo?: {
sitemap: string, // sitemap filename (e.g. "sitemap.xml")
googleIndexing?: string, // path to Google service account JSON key
},
});

Files are synced to S3 and served via CloudFront globally. Security headers (HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy) are applied automatically via the AWS managed SecurityHeadersPolicy.

export const docs = defineStaticSite({
dir: "dist",
build: "npx astro build",
});

When spa: true, CloudFront error responses redirect 403/404 to index.html, enabling client-side routing (React Router, Vue Router, etc.).

export const dashboard = defineStaticSite({
dir: "dist",
spa: true,
build: "npm run build",
});

Custom domain

Set domain to serve your site on a custom domain instead of the default *.cloudfront.net URL:

export const site = defineStaticSite({
dir: "dist",
build: "npm run build",
domain: "example.com",
});

When domain is set, Effortless:

  1. Finds an existing ACM certificate in us-east-1 that covers your domain
  2. Configures the CloudFront distribution with your domain as an alias and the SSL certificate
  3. If the certificate also covers www.example.com (exact or wildcard *.example.com) — automatically adds www as a second alias and sets up a 301 redirect from www.example.comexample.com via a CloudFront Function
  4. If the certificate does not cover www — deploys without www and prints a warning

Middleware (Lambda@Edge)

Add middleware to run custom Node.js code before CloudFront serves any page. Use it for authentication, access control, or redirects.

export const admin = defineStaticSite({
dir: "admin/dist",
domain: "admin.example.com",
middleware: async (request) => {
if (!request.cookies.session) {
return { redirect: "https://example.com/login" };
}
// return void → serve the page normally
},
});

The middleware function receives a simplified request object:

FieldTypeDescription
uristringRequest path (e.g. /admin/users)
methodstringHTTP method (GET, POST, etc.)
querystringstringRaw query string
headersRecord<string, string>Flattened request headers
cookiesRecord<string, string>Parsed cookies

Return values control what happens next:

ReturnEffect
void / undefinedContinue serving — the static file is returned normally
{ redirect: string, status?: 301 | 302 | 307 | 308 }Redirect to another URL (default: 302)
{ status: 403, body?: string }Block access with a 403 Forbidden response

When middleware is present, it replaces the default CloudFront Function — the middleware handles both your custom logic and URL rewriting (/path//path/index.html) automatically.

API route proxying

Use routes to proxy specific URL patterns to your API Gateway instead of S3. This eliminates CORS by serving frontend and API from the same domain.

import { api } from "./api";
export const app = defineStaticSite({
dir: "dist",
spa: true,
domain: "example.com",
routes: {
"/api/*": api,
},
});

Values are references to defineApi handlers. Effortless resolves the Function URL domain at deploy time and creates CloudFront cache behaviors for each pattern — with caching disabled, all HTTP methods allowed, and all headers forwarded.

Error pages

For non-SPA sites, Effortless generates a clean, minimal 404 page automatically. Both 403 (S3 access denied for missing files) and 404 are served with this page and a proper 404 HTTP status.

To use your own error page instead, set errorPage to a path relative to dir:

export const docs = defineStaticSite({
dir: "dist",
errorPage: "404.html",
});

For SPA sites (spa: true), error pages are not used — all paths route to index.html.

SEO — sitemap, robots.txt, Google Indexing

Add seo to auto-generate sitemap.xml and robots.txt at deploy time, and optionally submit pages to the Google Indexing API for faster crawling.

export const docs = defineStaticSite({
dir: "dist",
build: "npm run build",
domain: "example.com",
seo: {
sitemap: "sitemap.xml",
},
});

On every deploy, Effortless:

  1. Walks the dir directory and collects all .html files
  2. Generates a sitemap XML with <loc> entries for each page (skips 404.html and 500.html)
  3. Generates robots.txt with Allow: / and a Sitemap: directive pointing to your sitemap
  4. Uploads both to S3 (sitemap is skipped if you already have one in dir — e.g. from Astro’s sitemap plugin)

URL paths are normalized: about/index.html becomes https://example.com/about/, page.html stays as https://example.com/page.html.

Google Indexing API

Google can take days or weeks to discover new pages. The Indexing API lets you notify Google immediately when pages are published.

export const docs = defineStaticSite({
dir: "dist",
domain: "example.com",
seo: {
sitemap: "sitemap.xml",
googleIndexing: "~/google-service-account.json",
},
});

On deploy, Effortless submits all page URLs via the Indexing API. Already-submitted URLs are tracked in S3 and skipped on subsequent deploys — only new pages are submitted.

Setup:

  1. Create a Google Cloud service account and download the JSON key
  2. In Google Search Console, add the service account email as an Owner (Settings → Users and permissions)
  3. Set googleIndexing to the path of your JSON key file (relative to project root, or ~/ for home directory)

Built-in best practices:

  • URL rewriting — automatically resolves /path/ to /path/index.html via CloudFront Function.
  • SPA support — when spa: true, 403/404 errors return index.html for client-side routing.
  • Security headers — HSTS, X-Frame-Options, X-Content-Type-Options, and Referrer-Policy are applied automatically to all responses.
  • Error pages — non-SPA sites get a clean 404 page out of the box (overridable via errorPage).
  • API route proxying — use routes to forward path patterns to API Gateway, eliminating CORS for same-domain frontend + API setups.
  • Global distribution — served via CloudFront edge locations worldwide.
  • Custom domains — set domain for a custom domain with automatic ACM certificate lookup and optional www→non-www redirect.
  • Edge middleware — add middleware for auth checks, redirects, or access control via Lambda@Edge. Full Node.js runtime at the edge — JWT validation, cookie checks, custom logic.
  • SEO automation — auto-generate sitemap.xml and robots.txt at deploy time, submit new pages to Google Indexing API.
  • Orphan cleanup — when CloudFront Functions become unused (e.g. after config changes), they are automatically deleted on the next deploy.
  • Auto-infrastructure — S3 bucket, CloudFront distribution, Origin Access Control, CloudFront Function (or Lambda@Edge), cache invalidation, and SSL certificate configuration on deploy.

defineFifoQueue

Creates: SQS FIFO Queue + Lambda + Event Source Mapping + IAM permissions

export const orderQueue = defineFifoQueue({
// Optional — queue
batchSize?: number, // 1-10, default: 10
batchWindow?: number, // seconds (0-300), default: 0
visibilityTimeout?: number, // seconds (default: max of timeout or 30)
retentionPeriod?: number, // seconds (60-1209600, default: 345600 = 4 days)
contentBasedDeduplication?: boolean, // default: true
// Optional — lambda
memory?: number,
timeout?: number,
permissions?: Permission[], // additional IAM permissions
schema?: (input: unknown) => T, // validate & parse message body
setup?: ({ deps, config }) => C, // factory for shared state (cached on cold start)
deps?: { [key]: TableHandler }, // inter-handler dependencies
config?: { [key]: param(...) }, // SSM parameters
onAfterInvoke?: ({ ctx, deps, config, files }) => void | Promise<void>,
// Handler — choose one mode:
// Mode 1: per-message processing
onMessage: async ({ message, ctx, deps, config }) => { ... },
// Mode 2: batch processing
onBatch: async ({ messages, ctx, deps, config }) => { ... },
});

Callback arguments

All queue callbacks (onMessage, onBatch) receive:

ArgTypeDescription
message / messagesFifoQueueMessage<T> / FifoQueueMessage<T>[]Parsed messages with typed body
ctxCResult from setup() factory (if provided)
deps{ [key]: TableClient }Typed clients for dependent tables (if deps is set)
configResolveConfig<P>SSM parameter values (if config is set)

The FifoQueueMessage<T> object:

FieldTypeDescription
messageIdstringUnique message identifier
bodyTParsed body (JSON-decoded, then optionally schema-validated)
rawBodystringRaw unparsed message body string
messageGroupIdstringFIFO ordering key
messageDeduplicationIdstring?Deduplication ID
receiptHandlestringReceipt handle for acknowledgement
messageAttributesRecord<string, ...>SQS message attributes

Per-message processing

type OrderEvent = { orderId: string; action: string };
export const orderQueue = defineFifoQueue({
schema: unsafeAs<OrderEvent>(),
onMessage: async ({ message }) => {
console.log(`Order ${message.body.orderId}: ${message.body.action}`);
await processOrder(message.body);
},
});

Each message is processed individually. If one fails, only that message is retried via batchItemFailures. The rest of the batch succeeds.

Batch processing

export const notifications = defineFifoQueue({
schema: unsafeAs<Notification>(),
batchSize: 5,
onBatch: async ({ messages }) => {
await sendAll(messages.map(m => m.body));
},
});

All messages in a batch are processed together. If the handler throws, all messages are reported as failed.

Schema validation

export const events = defineFifoQueue({
schema: (input) => {
const obj = input as any;
if (!obj?.eventType) throw new Error("eventType is required");
return { eventType: obj.eventType as string, payload: obj.payload };
},
onMessage: async ({ message }) => {
// message.body is typed: { eventType: string; payload: unknown }
},
});

When schema throws, the message is reported as a batch item failure automatically.

Dependencies

import { orders } from "./orders.js";
export const orderProcessor = defineFifoQueue({
schema: unsafeAs<OrderEvent>(),
deps: () => ({ orders }),
onMessage: async ({ message, deps }) => {
// deps.orders is TableClient<Order>
await deps.orders.put({
pk: `ORDER#${message.body.orderId}`, sk: "STATUS",
data: { tag: "order", status: "processing" },
});
},
});

Dependencies are auto-wired: the framework sets environment variables, IAM permissions, and provides typed TableClient instances at runtime.

Built-in best practices:

  • Partial batch failures — each message is processed individually (onMessage mode). If one fails, only that message is retried via batchItemFailures. The rest of the batch succeeds.
  • FIFO ordering — messages within the same messageGroupId are delivered in order. Use message groups to partition work while maintaining ordering guarantees.
  • Content-based deduplication — enabled by default. SQS uses the message body hash to prevent duplicates within the 5-minute deduplication interval.
  • Typed messages — use schema: unsafeAs<OrderEvent>() or a validation function for typed message.body with automatic JSON parsing.
  • Schema validation — when schema is set, each message body is validated before your handler runs. Invalid messages are automatically reported as failures.
  • Typed dependenciesdeps provides typed TableClient<T> instances for DynamoDB tables with auto-wired IAM and env vars.
  • Cold start optimization — the setup factory runs once and is cached across invocations.
  • Auto-infrastructure — SQS FIFO queue, Lambda, event source mapping, and IAM permissions are all created on deploy from this single definition.

defineSchedule

Status: Planned — not yet implemented.

Creates: EventBridge Rule + Lambda + IAM permissions

export const daily = defineSchedule({
// Required
schedule: string, // "rate(1 hour)" or "cron(0 12 * * ? *)"
// Optional
memory?: number,
timeout?: DurationInput,
enabled?: boolean, // default true
handler: async (ctx: ScheduleContext) => {
// ctx.scheduledTime, ctx.ruleName available
}
});

Planned best practices:

  • Auto-infrastructure — EventBridge rule, Lambda, and IAM permissions are created on deploy. Toggle enabled to pause the schedule without deleting resources.

defineEvent

Status: Planned — not yet implemented.

Creates: EventBridge Rule + Lambda for custom events

export const orderCreated = defineEvent({
// Required
eventPattern: {
source: ["my.app"],
"detail-type": ["OrderCreated"],
},
// Optional
eventSchema?: (input: unknown) => T,
handler: async (event: T, ctx: EventContext) => {
// typed event
}
});

Planned best practices:

  • Typed events — when eventSchema is set, the event detail is parsed and validated before your handler runs.
  • Auto-infrastructure — EventBridge rule with pattern matching, Lambda, and IAM permissions are created on deploy.

defineBucket

Creates: S3 Bucket + (optional) Lambda + S3 Event Notifications

Like defineTable, defineBucket supports resource-only mode — omit event callbacks to create just the bucket, referenceable via deps from other handlers.

export const uploads = defineBucket({
// Optional — event filters
prefix?: string, // S3 key prefix filter (e.g. "images/")
suffix?: string, // S3 key suffix filter (e.g. ".jpg")
// Optional — lambda
memory?: number,
timeout?: DurationInput,
permissions?: Permission[], // additional IAM permissions
setup?: ({ bucket, deps, config }) => C, // factory for shared state (cached on cold start)
deps?: { [key]: Handler }, // inter-handler dependencies
config?: { [key]: param(...) }, // SSM parameters
onAfterInvoke?: ({ ctx, deps, config, files }) => void | Promise<void>,
// Event handlers — both optional
onObjectCreated?: async ({ event, bucket, ctx, deps, config }) => { ... },
onObjectRemoved?: async ({ event, bucket, ctx, deps, config }) => { ... },
});

When at least one event handler is provided, a Lambda is created with S3 event notifications for ObjectCreated:* and ObjectRemoved:* events, filtered by prefix/suffix if specified.

BucketEvent

Both onObjectCreated and onObjectRemoved receive a BucketEvent:

FieldTypeDescription
eventNamestringS3 event name (e.g. "ObjectCreated:Put", "ObjectRemoved:Delete")
keystringObject key (path within the bucket)
sizenumber?Object size in bytes (present for created events)
eTagstring?Object ETag (present for created events)
eventTimestring?ISO 8601 timestamp of the event
bucketNamestringS3 bucket name

Callback arguments

All event callbacks (onObjectCreated, onObjectRemoved) receive:

ArgTypeDescription
eventBucketEventS3 event record
bucketBucketClientTyped client for this bucket (auto-injected)
ctxCResult from setup() factory (if provided)
deps{ [key]: TableClient | BucketClient }Typed clients for dependent handlers (if deps is set)
configResolveConfig<P>SSM parameter values (if config is set)

BucketClient

Every bucket handler receives a bucket: BucketClient — a typed client for its own S3 bucket. Other handlers get it via deps.

BucketClient
put(key: string, body: Buffer | string, options?: { contentType?: string }): Promise<void>
get(key: string): Promise<{ body: Buffer; contentType?: string } | undefined>
delete(key: string): Promise<void>
list(prefix?: string): Promise<{ key: string; size: number; lastModified?: Date }[]>
bucketName: string

put — upload an object:

await bucket.put("images/photo.jpg", imageBuffer, { contentType: "image/jpeg" });
await bucket.put("data/config.json", JSON.stringify(config));

get — download an object. Returns undefined if not found:

const file = await bucket.get("images/photo.jpg");
if (file) {
console.log(file.body.length, file.contentType);
}

delete — remove an object:

await bucket.delete("images/old-photo.jpg");

list — list objects, optionally filtered by prefix:

const all = await bucket.list();
const images = await bucket.list("images/");
// [{ key: "images/a.jpg", size: 1024, lastModified: Date }, ...]

Event handlers

export const uploads = defineBucket({
prefix: "images/",
suffix: ".jpg",
onObjectCreated: async ({ event, bucket }) => {
const file = await bucket.get(event.key);
console.log(`New image: ${event.key}, size: ${file?.body.length}`);
},
onObjectRemoved: async ({ event }) => {
console.log(`Deleted: ${event.key}`);
},
});

Dependencies

import { orders } from "./orders.js";
export const invoices = defineBucket({
deps: () => ({ orders }),
onObjectCreated: async ({ event, deps }) => {
// deps.orders is TableClient<Order>
await deps.orders.put({
pk: "INVOICE#1", sk: "FILE",
data: { tag: "invoice", key: event.key, size: event.size ?? 0 },
});
},
});

Resource-only (no Lambda)

// Just creates the S3 bucket — no event notifications, no Lambda
export const assets = defineBucket({});

Use it as a dependency from other handlers:

import { assets } from "./assets.js";
export const api = defineApi({
basePath: "/uploads",
deps: () => ({ assets }),
post: async ({ req, deps }) => {
// deps.assets is BucketClient
await deps.assets.put("uploads/file.txt", req.body);
return { status: 201 };
},
});

Built-in best practices:

  • Filtered triggers — use prefix and suffix to limit which S3 events invoke the Lambda, reducing unnecessary invocations.
  • Self-clientbucket arg provides a typed BucketClient for the handler’s own bucket, auto-injected with no config.
  • Typed dependenciesdeps provides typed TableClient<T> and BucketClient instances with auto-wired IAM and env vars.
  • Resource-only mode — omit event handlers to create just the bucket. Reference it via deps from other handlers.
  • Cold start optimization — the setup factory runs once and is cached across invocations. Receives bucket (self-client) alongside deps and config.
  • Error isolation — each S3 event record is processed individually. If one fails, the error is logged and the remaining records continue processing.
  • Auto-infrastructure — S3 bucket, Lambda, S3 event notifications, and IAM permissions are all created on deploy from this single definition.

defineMailer

Creates: SES Email Identity (domain verification + DKIM)

defineMailer is a resource-only definition — it doesn’t create a Lambda function. It sets up an SES email identity for a domain and provides a typed EmailClient to other handlers via deps.

export const mailer = defineMailer({
domain: "myapp.com",
});

On first deploy, DKIM DNS records are printed to the console. Add them to your DNS provider to verify the domain. Subsequent deploys check verification status and skip if already verified.

Using from other handlers

Import the mailer and add it to deps. The framework injects a typed EmailClient with SES send permissions auto-wired.

import { defineApi } from "effortless-aws";
import { mailer } from "./mailer.js";
export const api = defineApi({
basePath: "/welcome",
deps: () => ({ mailer }),
post: async ({ req, deps }) => {
await deps.mailer.send({
from: "hello@myapp.com",
to: req.body.email,
subject: "Welcome!",
html: "<h1>Welcome aboard!</h1>",
});
return { status: 200, body: { sent: true } };
},
});

EmailClient

The EmailClient injected via deps has a single method:

EmailClient
send(opts: SendEmailOptions): Promise<void>

send — send an email via SES. At least one of html or text is required.

await deps.mailer.send({
from: "hello@myapp.com", // must be on a verified domain
to: "user@example.com", // string or string[]
subject: "Hello!",
html: "<h1>Hi!</h1>", // HTML body
text: "Hi!", // plain text fallback (optional when html is set)
});

Multiple recipients:

await deps.mailer.send({
from: "team@myapp.com",
to: ["alice@example.com", "bob@example.com"],
subject: "Team update",
text: "New release is out!",
});

Built-in best practices:

  • Resource-only — no Lambda is created. The mailer is purely an SES identity + typed client for deps.
  • DKIM verification — on first deploy, RSA 2048-bit DKIM signing is configured automatically. DNS records are printed to the console.
  • Typed clientdeps.mailer is an EmailClient with a typed send() method. At least one of html or text is required at compile time.
  • Lazy SDK init — the SES client is created on first send() call, not on cold start. Zero overhead if the email path is not hit.
  • Auto-IAM — the dependent Lambda gets ses:SendEmail and ses:SendRawEmail permissions automatically.
  • Cleanupeff cleanup removes SES identities along with all other resources.