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:

DefinitionDescription
defineApiHTTP API with routes
defineTableDynamoDB table with optional stream processing
defineAppSSR app with CloudFront
defineStaticSiteStatic site with CloudFront
defineFifoQueueSQS FIFO queue with message processing
defineCronScheduled Lambda (cron / rate)
defineBucketS3 bucket with optional event handlers
defineMailerSES email identity for sending emails
defineMcpMCP server with tools, resources, and prompts

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 }),
})
.setup(({ deps }) => ({ users: deps.users, uploads: deps.uploads }))
.post("/upload", async ({ users, uploads }) => {
await users.put({
pk: "USER#1", sk: "PROFILE",
data: { tag: "user", name: "Alice", email: "alice@example.com" },
});
await uploads.put("avatars/user-1.png", avatarBuffer);
return { status: 201, body: { ok: true } };
});

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 ({ deps, config }) => ({
users: deps.users,
db: createPool(config.threshold),
}),
onRecord: async ({ record, table, users, db }) => {
// record.new?.data is z.infer<typeof Order> | undefined
// users is TableClient<User> (from setup return)
// db is Pool (from setup return)
},
});

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 its properties are spread directly into callback arguments — no ctx wrapper. Supports async.

When deps, config, or files are declared, receives them as argument. These are only available in setup, not in callbacks.

// No deps/config — zero-arg
setup: () => ({ pool: createPool() }),
// → callbacks receive: { pool, ...otherArgs }
// With deps and/or config — only available in setup
setup: async ({ deps, config }) => ({
users: deps.users,
pool: createPool(config.dbUrl),
}),
// → callbacks receive: { users, pool, ...otherArgs }

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. Deps are available in setup only — wire them into the setup return to use in callbacks.

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. Config values are available in setup only — wire them into the setup return to use in callbacks.

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, pool }) => {
console.error("Handler failed:", error);
return { status: 500, body: { error: "Something went wrong" } };
},

onCleanup

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 setup context as arguments. Supports async. If onCleanup throws, the error is logged but does not affect the handler’s response.

const buffer: LogEntry[] = [];
export default defineApi({
basePath: "/api",
onCleanup: async ({ ctx }) => {
// 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.

  • Chained methods — routes defined via .get(), .post(), .put(), .delete(), .patch() with path patterns as the first argument
  • Shared deps, config, static, onError, onCleanup in the options object; .setup() is chained
  • deps and config are available in .setup() only — wire them into the setup return
  • Setup return properties are spread into route handler args
  • Unmatched routes return 404 automatically
export default defineApi({
// Required
basePath: `/${string}`, // e.g. "/api" — prefix for all routes
// Optional — Lambda
lambda?: { memory?, timeout?, permissions? },
stream?: boolean, // enable response streaming (SSE)
// Optional — wiring
deps?: () => { [key]: Handler },
config?: { [key]: secret() | param(...) },
static?: string[],
onError?: ({ error, req, ...ctx }) => HttpResponse,
onCleanup?: ({ ...ctx }) => void | Promise<void>,
})
// Chained setup — runs once on cold start
.setup(({ deps, config, files, enableAuth }) => C)
// Chained route definitions — method(path, handler, options?)
.get("/users", async ({ req, input, ...ctx }) => {
return { status: 200, body: [...] };
})
.post("/users", async ({ req, input, ...ctx }) => {
return { status: 201, body: { ... } };
})
.post("/login", async ({ input, auth }) => {
return auth.createSession({ userId: "..." });
}, { public: true }); // accessible without auth

Route handler arguments

All route handlers receive a single object with:

ArgTypeDescription
reqHttpRequestFull HTTP request (method, path, headers, query, body, rawBody, params)
inputunknownMerged query params + parsed body
streamResponseStreamResponse stream (only when stream: true)
...ctxspreadAll properties from setup return, spread directly
authAuthHelpers<A>Session helpers (only when enableAuth is used in setup)

Authentication

Auth is configured via the enableAuth helper injected into setup args. The HMAC secret must be explicit — use secret() in config:

import { defineApi, defineTable, secret } from "effortless-aws";
export const apiKeys = defineTable({ schema: unsafeAs<ApiKey>() });
export const api = defineApi({
basePath: "/api",
deps: () => ({ apiKeys }),
config: { sessionSecret: secret() },
})
.setup(({ deps, config, enableAuth }) => ({
auth: enableAuth<Session>({
secret: config.sessionSecret,
expiresIn: "7d",
apiToken: {
header: "x-api-key",
verify: async (value) => {
const items = await deps.apiKeys.query({ pk: value });
const key = items[0];
if (!key) return null;
return { userId: key.sk, role: key.data.role };
},
cacheTtl: "5m",
},
}),
}))
.get("/me", async ({ auth }) => ({
status: 200,
body: { session: auth.session },
}))
.post("/login", async ({ input, auth }) => {
return auth.createSession({ userId: input.userId, role: input.role });
}, { public: true })
.post("/logout", async ({ auth }) => auth.clearSession());

Auth helpers in route args:

  • auth.createSession(data) — create signed session cookie
  • auth.clearSession() — clear session cookie
  • auth.session — current session data (A | undefined)
  • Routes without { public: true } third argument require a valid session (401 if missing)
  • API token takes priority over cookie when both are present

Dependencies via setup

import { orders } from "./orders.js";
export const api = defineApi({
basePath: "/orders",
deps: () => ({ orders }),
})
.setup(({ deps }) => ({ orders: deps.orders }))
.post("/create", async ({ orders, input }) => {
await orders.put({
pk: "USER#123", sk: "ORDER#456",
data: { tag: "order", ...input },
});
return { status: 201, body: { ok: true } };
});

Dependencies are auto-wired: the framework sets environment variables, IAM permissions, and provides typed TableClient instances at runtime. Deps are available in .setup() only — spread them into callbacks via the setup return.

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.
  • 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
lambda?: { memory?, timeout?, permissions? },
setup?: ({ table, deps, config, files }) => C, // deps/config only in setup
deps?: () => { [key]: Handler },
config?: { [key]: secret() | param(...) },
onCleanup?: ({ ctx }) => void | Promise<void>,
// Stream handler — choose one mode:
// Mode 1: per-record processing
onRecord: async ({ record, batch, table, ...ctx }) => { ... },
// Mode 2: batch processing (return { failures } for partial batch failure)
onRecordBatch: async ({ records, table, ...ctx }) => { ... },
});

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, onRecordBatch) 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)
...ctxspreadAll properties from setup return, spread directly

deps and config are available in setup only — wire them into the setup return to use in callbacks.

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,
onRecordBatch: 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. Return { failures: string[] } with sequence numbers for partial batch failure reporting.

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 }),
setup: ({ deps }) => ({ users: deps.users }),
onRecord: async ({ record, users }) => {
const userId = record.new?.data.userId;
if (userId) {
const user = await users.get({ pk: `USER#${userId}`, sk: "PROFILE" });
console.log(`Order by ${user?.data.name}`);
}
}
});

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.
  • 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 onRecordBatch 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, // deps/config only in setup
deps?: () => { [key]: Handler },
config?: { [key]: secret() | param(...) },
onCleanup?: ({ ctx }) => void | Promise<void>,
// Handler — choose one mode:
// Mode 1: per-message processing
onMessage: async ({ message, ...ctx }) => { ... },
// Mode 2: batch processing (return { failures } for partial batch failure)
onMessageBatch: async ({ messages, ...ctx }) => { ... },
});

Callback arguments

All queue callbacks (onMessage, onMessageBatch) receive:

ArgTypeDescription
message / messagesFifoQueueMessage<T> / FifoQueueMessage<T>[]Parsed messages with typed body
...ctxspreadAll properties from setup return, spread directly

deps and config are available in setup only — wire them into the setup return to use in callbacks.

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,
onMessageBatch: 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. Return { failures: string[] } with messageIds for partial batch failure reporting.

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 }),
setup: ({ deps }) => ({ orders: deps.orders }),
onMessage: async ({ message, orders }) => {
// orders is TableClient<Order>
await 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.

defineCron

Creates: EventBridge Scheduler + Lambda + IAM permissions

export const cleanup = defineCron({ schedule: "rate(2 hours)" })
.onTick(async () => {
console.log("running cleanup");
});

Options

OptionTypeDescription
scheduleScheduleExpressionRequired. "rate(5 minutes)", "rate(1 hour)", "cron(0 9 * * ? *)"
timezoneTimezoneIANA timezone (default: UTC). Full autocomplete for 418 zones.

Builder chain

MethodDescription
.deps(() => ({ ... }))Declare dependencies (tables, queues, buckets, mailers)
.config(({ defineSecret }) => ({ ... }))Declare SSM secrets
.include("glob")Include static files in Lambda bundle. Chainable.
.setup({ memory, timeout, ... })Configure Lambda settings only
.setup(async ({ deps, config, files }) => ({ ... }))Cold-start init
.setup(fn, { memory, timeout, ... })Cold-start init + Lambda settings
.onError(({ error }) => { ... })Error handler for onTick failures
.onCleanup(async () => { ... })Runs after each invocation
.onTick(async (ctx) => { ... })Terminal. Called on each scheduled invocation

Full example

import { defineCron } from "effortless-aws";
import { orders } from "./orders.js";
export const sync = defineCron({
schedule: "cron(0 18 ? * MON-FRI *)",
timezone: "Europe/Moscow",
})
.deps(() => ({ orders }))
.config(({ defineSecret }) => ({ apiKey: defineSecret() }))
.include("templates/*.html")
.setup(async ({ deps, config, files }) => ({
db: deps.orders,
key: config.apiKey,
tpl: files,
}), { memory: 512, timeout: "5m" })
.onError(({ error }) => console.error("sync failed", error))
.onTick(async ({ db, key, tpl }) => {
const html = tpl.read("templates/report.html");
const expired = await db.scan();
// process expired orders...
});

Schedule expressions

Rate — run at fixed intervals (strictly typed units):

"rate(5 minutes)"
"rate(1 hour)"
"rate(1 day)"

Cron — run at specific times (6 fields: min hour dom month dow year):

"cron(0 9 * * ? *)" // daily at 9:00 UTC
"cron(0 9 ? * MON-FRI *)" // weekdays at 9:00
"cron(0/15 * * * ? *)" // every 15 minutes

Timezone

Pass any IANA timezone — EventBridge Scheduler handles DST transitions automatically:

defineCron({
schedule: "cron(0 9 * * ? *)",
timezone: "America/New_York", // 9:00 EST in winter, 9:00 EDT in summer
})

Built-in best practices:

  • Auto-infrastructure — EventBridge Scheduler, Lambda, and IAM permissions are all created on deploy from this single definition.
  • Typed rate expressionsrate() units (minute, hours, day, etc.) are validated at compile time.
  • 418 IANA timezones — full autocomplete, DST-aware. Generated from Intl.supportedValuesOf("timeZone").
  • Cold start optimizationsetup runs once and is cached across invocations.
  • Same builder pattern.deps(), .config(), .include(), .setup() work identically across all handler types.

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, // deps/config only in setup
deps?: () => { [key]: Handler },
config?: { [key]: secret() | param(...) },
onCleanup?: ({ ctx }) => void | Promise<void>,
// Event handlers — both optional
onObjectCreated?: async ({ event, bucket, ...ctx }) => { ... },
onObjectRemoved?: async ({ event, bucket, ...ctx }) => { ... },
});

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)
...ctxspreadAll properties from setup return, spread directly

deps and config are available in setup only — wire them into the setup return to use in callbacks.

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 }),
setup: ({ deps }) => ({ orders: deps.orders }),
onObjectCreated: async ({ event, orders }) => {
// orders is TableClient<Order>
await 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 }),
})
.setup(({ deps }) => ({ assets: deps.assets }))
.post("/upload", async ({ req, assets }) => {
// assets is BucketClient
await assets.put("uploads/file.txt", req.body);
return { status: 201, body: { ok: true } };
});

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 }),
})
.setup(({ deps }) => ({ mailer: deps.mailer }))
.post("/send", async ({ req, mailer }) => {
await 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.