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:
| Definition | Creates | Handler required? |
|---|---|---|
| defineApi | Lambda Function URL (single Lambda, multiple routes) | Optional (get / post) |
| defineTable | DynamoDB table + optional stream Lambda | No — table-only when no onRecord/onBatch |
| defineApp | CloudFront + Lambda Function URL serving SSR | No (built-in file server) |
| defineStaticSite | S3 + CloudFront + optional Lambda@Edge | No (optional middleware) |
| defineFifoQueue | SQS FIFO + Lambda | Yes (onMessage/onBatch) |
| defineSchedule | EventBridge + Lambda | Yes — Planned |
| defineEvent | EventBridge + Lambda | Yes — Planned |
| defineBucket | S3 bucket + optional event Lambda | No — resource-only when no onObjectCreated/onObjectRemoved |
| defineMailer | SES email identity | No — 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 streamexport const users = defineTable({ schema: unsafeAs<User>(),});
// Just a bucket — no Lambda, no event notificationsexport const uploads = defineBucket({});
// API that writes to the table and bucketexport 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 Zodschema: (input) => OrderSchema.parse(input),
// Plain TypeScriptschema: (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-argsetup: () => ({ pool: createPool() }),
// With deps and/or configsetup: 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 EmailClientconfig
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 stringpermissions
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,onAfterInvokeacross 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
setupfactory runs once on cold start and is cached across invocations. - Schema validation — when
schemais set, the POST body is parsed and validated before your handler runs. Invalid requests get a 400 response automatically. - Typed dependencies —
depsprovides typedTableClient<T>,BucketClient, andEmailClientinstances 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:
| Attribute | Type | Purpose |
|---|---|---|
pk | String | Partition key |
sk | String | Sort key |
tag | String | Entity type discriminant (auto-extracted from your data) |
data | Map | Your domain data (typed as T) |
ttl | Number | Optional 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 validationexport const orders = defineTable({ schema: unsafeAs<Order>(),});
// Option 2: schema function — with runtime validationexport 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:
| Arg | Type | Description |
|---|---|---|
record / records | TableRecord<T> / TableRecord<T>[] | Stream records with typed new/old TableItem<T> values |
table | TableClient<T> | Typed client for this table (auto-injected) |
ctx | C | Result from setup() factory (if provided) |
deps | { [key]: TableClient } | Typed clients for dependent tables (if deps is set) |
config | ResolveConfig<P> | SSM parameter values (if config is set) |
Stream records follow the TableItem<T> structure:
record.eventName // "INSERT" | "MODIFY" | "REMOVE"record.new?.pk // stringrecord.new?.sk // stringrecord.new?.tag // string (entity discriminant)record.new?.data // T (your typed domain data)record.new?.ttl // number | undefinedrecord.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: stringput — 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 existsawait 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 userconst orders = await table.query({ pk: "USER#123" });
// Orders with sk prefixconst 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 > :vsk: { gte: "value" } // sk >= :vsk: { lt: "value" } // sk < :vsk: { lte: "value" } // sk <= :vsk: { between: ["a", "z"] } // sk BETWEEN :v1 AND :v2
// Pagination and orderingconst 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 Lambdaexport const users = defineTable({ schema: unsafeAs<User>(),});Built-in best practices:
- Single-table design — fixed
pk/sk/tag/data/ttlstructure. 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.schemavalidates thedataportion of stream records. - Table self-client —
tablearg provides a typedTableClient<T>for the handler’s own table, auto-injected with no config. - Smart updates —
update()auto-prefixesdata.for domain fields, so you can do partial updates without reading the full item. - Typed dependencies —
depsprovides typedTableClient<T>instances for other tables with auto-wired IAM and env vars. - Batch accumulation —
onRecordreturn values are collected intoresultsforonBatchComplete. Use this for bulk writes, aggregations, or reporting. - Auto-TTL — TTL is always enabled on the
ttlattribute. Set it onput()orupdate()and DynamoDB auto-deletes expired items. - Conditional writes — use
{ ifNotExists: true }onput()for idempotent inserts. - Cold start optimization — the
setupfactory runs once and is cached across invocations. - Progressive complexity — omit handlers for table-only. Add
onRecordfor stream processing. AddonBatchfor batch mode. Adddepsfor 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:
- Finds an existing ACM certificate in us-east-1 that covers your domain
- Configures the CloudFront distribution with your domain as an alias and the SSL certificate
- If the certificate also covers
www.example.com(exact or wildcard*.example.com) — automatically addswwwas a second alias and sets up a 301 redirect fromwww.example.com→example.comvia a CloudFront Function - If the certificate does not cover
www— deploys withoutwwwand 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:
| Field | Type | Description |
|---|---|---|
uri | string | Request path (e.g. /admin/users) |
method | string | HTTP method (GET, POST, etc.) |
querystring | string | Raw query string |
headers | Record<string, string> | Flattened request headers |
cookies | Record<string, string> | Parsed cookies |
Return values control what happens next:
| Return | Effect |
|---|---|
void / undefined | Continue 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:
- Walks the
dirdirectory and collects all.htmlfiles - Generates a sitemap XML with
<loc>entries for each page (skips404.htmland500.html) - Generates
robots.txtwithAllow: /and aSitemap:directive pointing to your sitemap - 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:
- Create a Google Cloud service account and download the JSON key
- In Google Search Console, add the service account email as an Owner (Settings → Users and permissions)
- Set
googleIndexingto 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.htmlvia CloudFront Function. - SPA support — when
spa: true, 403/404 errors returnindex.htmlfor 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
routesto 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
domainfor a custom domain with automatic ACM certificate lookup and optional www→non-www redirect. - Edge middleware — add
middlewarefor 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.xmlandrobots.txtat 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:
| Arg | Type | Description |
|---|---|---|
message / messages | FifoQueueMessage<T> / FifoQueueMessage<T>[] | Parsed messages with typed body |
ctx | C | Result from setup() factory (if provided) |
deps | { [key]: TableClient } | Typed clients for dependent tables (if deps is set) |
config | ResolveConfig<P> | SSM parameter values (if config is set) |
The FifoQueueMessage<T> object:
| Field | Type | Description |
|---|---|---|
messageId | string | Unique message identifier |
body | T | Parsed body (JSON-decoded, then optionally schema-validated) |
rawBody | string | Raw unparsed message body string |
messageGroupId | string | FIFO ordering key |
messageDeduplicationId | string? | Deduplication ID |
receiptHandle | string | Receipt handle for acknowledgement |
messageAttributes | Record<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 (
onMessagemode). If one fails, only that message is retried viabatchItemFailures. The rest of the batch succeeds. - FIFO ordering — messages within the same
messageGroupIdare 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 typedmessage.bodywith automatic JSON parsing. - Schema validation — when
schemais set, each message body is validated before your handler runs. Invalid messages are automatically reported as failures. - Typed dependencies —
depsprovides typedTableClient<T>instances for DynamoDB tables with auto-wired IAM and env vars. - Cold start optimization — the
setupfactory 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
enabledto 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
eventSchemais 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:
| Field | Type | Description |
|---|---|---|
eventName | string | S3 event name (e.g. "ObjectCreated:Put", "ObjectRemoved:Delete") |
key | string | Object key (path within the bucket) |
size | number? | Object size in bytes (present for created events) |
eTag | string? | Object ETag (present for created events) |
eventTime | string? | ISO 8601 timestamp of the event |
bucketName | string | S3 bucket name |
Callback arguments
All event callbacks (onObjectCreated, onObjectRemoved) receive:
| Arg | Type | Description |
|---|---|---|
event | BucketEvent | S3 event record |
bucket | BucketClient | Typed client for this bucket (auto-injected) |
ctx | C | Result from setup() factory (if provided) |
deps | { [key]: TableClient | BucketClient } | Typed clients for dependent handlers (if deps is set) |
config | ResolveConfig<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: stringput — 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 Lambdaexport 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
prefixandsuffixto limit which S3 events invoke the Lambda, reducing unnecessary invocations. - Self-client —
bucketarg provides a typedBucketClientfor the handler’s own bucket, auto-injected with no config. - Typed dependencies —
depsprovides typedTableClient<T>andBucketClientinstances with auto-wired IAM and env vars. - Resource-only mode — omit event handlers to create just the bucket. Reference it via
depsfrom other handlers. - Cold start optimization — the
setupfactory runs once and is cached across invocations. Receivesbucket(self-client) alongsidedepsandconfig. - 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 client —
deps.maileris anEmailClientwith a typedsend()method. At least one ofhtmlortextis 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:SendEmailandses:SendRawEmailpermissions automatically. - Cleanup —
eff cleanupremoves SES identities along with all other resources.