lambder 4.9.1 → 5.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Readme.md DELETED
@@ -1,1032 +0,0 @@
1
- # Lambder - Serverless NodeJS Web Framework (v4)
2
-
3
- Lambder is a highly opinionated dynamic serverless framework designed to facilitate the management and implementation of routes and APIs within AWS Lambda functions, specifically tailored for TypeScript projects. It provides a streamlined approach to handling HTTP requests, managing sessions, and defining API routes, making serverless application development more intuitive and structured.
4
-
5
- **New in 4.9:**
6
-
7
- - **Mandatory authorization on public APIs**: `requirePublicApiGuards: true` at creation makes `guards` a required field of every `addApi`, the same way `requireSessionApiGuards` does for session APIs, at the type level and at registration. Public APIs are open by default and that stays the default; what turning it on buys is that a public endpoint's openness becomes a written decision rather than an omission. The ones anybody may call declare a named no-op guard carrying the reason (`guards: { open: "Static strings already in the bundle." }`), the ones that authorize their caller some other way (a signature, a device secret, a one-shot token) name where that happens, and one grep over the guard names then lists every public door and why it is open. The two flags are independent, so an app can require either or both.
8
- - **An empty guards option is refused**: `guards: {}` and `guards: []` were inhabited by the option type and passed the require\*ApiGuards field check while normalizing to zero entries, so a declaration that authorized nothing satisfied a requirement that exists to make authorization explicit. Both are now compile errors (every form of the option is non-empty by construction) and a registration error for a plain-JS caller, whichever flag is on or off. Requiring the chosen key also rejects `guards: { theGuard: undefined }`, which an optional property accepted and which reached the guard's handler with an undefined param.
9
-
10
- **New in 4.8:**
11
-
12
- - **Grouped cache keys**: `LambderDdbCache` keys may be a `{ pk, sk }` pair instead of a string, which stores related entries in one partition: `{ pk: "division:ist-34", sk: "1700:1800" }` keeps every cached window of one division together. `deletePartition(pk)` then drops the whole group without knowing which sort keys exist, and `listSortKeys(pk, { prefix, limit })` reads back what is currently cached under it. The group invalidation a cache of derived, per-entity values needs, in place of remembering every key ever written or waiting out the TTL. Reads stay one request, and the memory layer, single-flight and fill lease stay per entry. Only the `pk` part is hashed, so the sort key is queryable; a caller's `#` is escaped rather than refused (`~`→`~0`, `#`→`~1`). Plain string keys keep their exact item layout, so a live table needs no migration and both forms can share a partition.
13
- - **`guards` on the API contract**: each contract entry now carries the `guards` option exactly as declared (`ApiContractType["getUser"]["guards"]` is the literal `{ readonly orgPermission: "USERS.MANAGE" }`), so a client-side map of what an API needs can be pinned to the server's own declaration with `satisfies` instead of a test that reads the server source.
14
-
15
- **New in 4.7:**
16
-
17
- - **Compressed request payloads**: `requestCompression` on `LambderCaller` gzips the payload of any call whose JSON reaches a threshold (`true` is `{ minBytes: 4096 }`), sending it as `payloadGz` beside its byte length instead of `payload` whenever that is actually smaller; the server restores it before rate-limit key slices, guards and input validation, so no call site, handler or schema changes. Chiefly a way to fit a large payload under Lambda's ~6MB invoke cap, which applies to the compressed bytes. The envelope stays `application/json` with its routing fields in plain text, so gateways, CDNs and mocks are unaffected. `maxRequestPayloadBytes` (default 20MB) bounds what a body may expand to.
18
- - **One compression codec**: `shared/LambderCompressionCodec.ts` is now the only place Lambder compresses or decompresses bytes. Its `restoreBoundedText(bytes, declaredBytes, encoding)` carries the guarantee every compressed value in Lambder depends on, at rest and on the wire: the declared UTF-8 byte length bounds the decompression AND must match the result exactly, so a truncated, tampered or endlessly-expanding input fails instead of decoding to something merely plausible. Compression is split across three modules by what each one needs: the codec (zlib), the option and its resolver (pure, so the browser entry can resolve the caller's option), and the request payload format (the browser's CompressionStream). `stores/LambderDdbCompression.ts` is retired into them.
19
- - **One compression option, now everywhere**: the HTTP response option and the new request option resolve through the same `resolveCompressionOption` the DynamoDB stores and sessions use, and every site's option is the one generic `LambderCompressionOption<Settings>`. Same vocabulary at every site (`true` for that site's defaults, `false` for off, an object to override, `minBytes` as the threshold, `quality` as the Brotli quality, `encodings` as the negotiation order), same `Settings | null` resolved shape, and the same startup validation: `compression: { quality: 99 }` or `{ encodings: [] }` on a response is now a construction error instead of being silently ignored, and a field set to `undefined` keeps its default.
20
- - **Brotli responses**: response compression now negotiates `br` before `gzip`, smaller at comparable speed (15-25% on markup and prose, substantially more on the repetitive record lists API responses tend to be), which is bandwidth saved and headroom gained against the ~6MB response cap. `compression: { encodings: ["gzip"] }` opts out, `quality` (default 5) tunes it.
21
- - **Mandatory authorization on session APIs**: `requireSessionApiGuards: true` at creation makes `guards` a required field of every `addSessionApi`, at the type level (a missing declaration is a compile error at the registration site) and at registration (a plain-JS caller throws). An API the session alone authorizes declares a named no-op session guard, so every opt-out is explicit and one grep lists them all. The class of defect this closes is "the guard existed and the endpoint did not use it", which review discipline does not catch as a surface grows.
22
-
23
- **New in 4.6:**
24
-
25
- - **Cookies as a first-class concern**: `res.setCookie(name, value, options)` and `res.clearCookie(name, options)` serialize Set-Cookie headers through the `cookie` package (defaults Path=/, SameSite=Lax, Secure; a function-form `domain` resolves against the request hostname, the same option the session takes), replacing hand-built header strings; `serializeCookie`/`serializeClearCookie` are exported for code holding a response. `ctx.cookieList` keeps every value a cookie name arrived with beside the first-wins `ctx.cookie`.
26
- - **Session cookie scope changes heal**: a cookie's identity is (name, domain, path), so changing the session's `cookie.domain` or `path` on a live deployment leaves the old copy in every browser beside the new one, and a whole-header parse silently picks whichever the browser lists first. The controller now tries every copy of the session cookie (record and CSRF pairing checked per copy), logs the ambiguity, and evicts the stale host-only twin from the response, so a migrated browser recovers on its first request instead of answering `sessionExpired` until the old cookie expires.
27
-
28
- **New in 4.5:**
29
-
30
- - **`files` at creation replaces `publicPath`** (and `servePublicFiles({ source })`): one `LambderFileSource` configured once, `files: new LambderLocalFileSource({ root: path.resolve("./public") })` for the folder bundled with the deployment, `new LambderS3FileSource({...})` for S3 or R2, or your own `{ read(relativePath) }`. The instance owns one reader over it (`lambder.files`): path rule, in-memory file cache and compiled-template cache in one place, shared by `servePublicFiles`, `serveIndexHtml`, `res.file` and `res.templateFile`, so a build hosted from a bucket serves its index.html and templates from the bucket too, cached the same way as its assets. The cache is tuned or disabled beside the source, `files: { source, memoryCache }`, and `memoryCache` leaves `servePublicFiles`; `res.file` loses its SPA-era `fallback` option (the fallback chain replaced it).
31
-
32
- **New in 4.4:**
33
-
34
- - **`guardInputsProvider`** on `LambderCaller`: supply guardInput-mode guard values for every call from one place (the organization the UI is on, a device token) instead of at each call site; per-call `guardInputs` merge on top. Name the covered guards in the caller's second type parameter, `new LambderCaller<Contract, "orgPermission">({ guardInputsProvider, ... })`: calls to APIs whose guardInput guards are all covered no longer require the options argument, uncovered ones (a Turnstile token) still do, and naming guards makes the provider itself mandatory.
35
- - **Public file sources**: `servePublicFiles({ source })` serves from any `LambderPublicFileSource`: `LambderLocalFileSource` (a folder; the default, over `publicPath`), `LambderS3FileSource` (S3, or Cloudflare R2 and other S3-compatible stores via `clientConfig.endpoint`; `@aws-sdk/client-s3` is an optional peer dependency loaded on first read), or your own `{ read(relativePath) }`. The handler's traversal check, memory cache, mime fallback from the extension, Cache-Control, ETag and compression apply to every source. The `cacheControl` callback receives the relative file path.
36
- - **`expireSessionDataAllByKey(sessionKey)`** on the session manager and controller: marks the data of every session of a subject stale, so each renews via `dataRefresh` on its next read. The way to apply a role or permission change to a user immediately, without logging them out (`deleteSessionAllByKey`) and without waiting for the data TTL.
37
-
38
- **New in 4.3:**
39
-
40
- - **Compressed sessions**: `session.data` is stored Brotli-compressed by default, as `dataBr` + `dataBytes` on the record, the same scheme LambderDdbCache and LambderDdbIdempotency use (one shared implementation). A session that caches roles, permissions or product lists shrinks 2-3x and stays within one DynamoDB read unit for longer. `session.compression` is `true` by default (the same as `{ minBytes: 0 }`: every record compressed); `false` turns it off and `{ minBytes }` compresses only from that JSON size. Records written under either setting read back, so it can be switched on or off on a live table.
41
-
42
- - **One compression option everywhere**: `LambderDdbCache`, `LambderDdbIdempotency` and sessions take the same `compression` option (`true` for that store's defaults, `false` for off, `{ minBytes, quality }` to override), resolved by one shared function, and each store records a value's encoding so the option can be switched on or off on a live table. Defaults keep the previous behavior: the cache compresses everything, the idempotency store from 1KB. `compressionQuality` on the cache and idempotency store is replaced by `compression: { quality }`, and HTTP `compression` accepts `true` as `{ minBytes: 860 }`.
43
-
44
- **New in 4.2:**
45
-
46
- - **Rate-limit budgets**: a policy's `budget` is `"perApi"` (default: each referencing API gets its own counter, so the numbers are a per-API ceiling and three APIs on a 60/min policy allow one IP 180/min in total) or `"perPolicy"` (one counter shared by every API referencing the policy). The policy is the group, and two separate shared budgets are two policies.
47
- - **Per-API tuning**: the `rateLimit` option gained a map form like guards, `rateLimit: { lookupPerIp: { perMin: 20 } }`, which merges window overrides over a perApi policy's own (a tighter burst keeps the policy's daily cap). Overriding the windows of a perPolicy policy is a startup error; `errorMessage` is overridable on either.
48
- - **Retry-After**: a 429 carries the exceeded window's reset as a `Retry-After` header (CORS exposes it by default via the new `exposeHeaders` option), `LambderCaller` failure outcomes surface it as `retryAfterSeconds`, `LambderDdbRateLimiter.isRateLimited()` answers `false | { window, limit, resetAt }`, and `LambderApiError`/`refuse()` accept `headers`.
49
- - **One refusal shape, with codes**: `LambderRefusalMessage` gained an optional machine-readable `code` (`refuse(content, { code })`), so clients branch and translate on an identifier instead of string-matching prose. Every refusal the framework itself authors (rate limit 429, idempotency 409 and 400, unknown API) is a `LambderRefusalMessage` stamped with a `LAMBDER_REFUSAL_CODES` constant under the reserved `lambder/` prefix; a rate-limit policy's own `errorMessage` (typed as a refusal message) inherits `lambder/rate-limited` unless it sets a code.
50
- - **One validation path**: preflight slices (guard `apiInput`/`guardInput`, rate-limit `apiInput` keys) answer through `setApiInputValidationErrorHandler` exactly like the API's own schema.
51
-
52
- **New in v4:**
53
-
54
- - **Declarative auth as guards**: guards take per-API params (`guards: { orgPermission: "SOME.PERMISSION" }`), can require a session (`session: true`, compile-checked), and RETURN typed values that land on the handler's `ctx.guardData[name]`. Together with the apiInput/guardInput input modes, permission checks and device auth become registration-time declarations instead of per-handler boilerplate.
55
- - **Hardened policy layer**: rate-limit policies can share one counter across APIs (now `budget: "perPolicy"`); idempotency replays answer before rate limits, survive client IP changes (key-scoped for public APIs, 16-char minimum keys), store full response headers, refuse to store Set-Cookie responses, and Brotli-compress stored bodies of 1KB+ so the ~350KB replay budget applies to compressed bytes.
56
- - **Secrets hashed at rest**: session records store only sha256 hashes of the bearer secrets, so a session-table read yields no usable cookies; `LambderSessionReadError` keeps a DynamoDB blip from reading as a logout.
57
- - **Three package entry points**: `lambder` (server), `lambder/client` (browser-safe by construction: no AWS SDK, no Node built-ins), `lambder/testing` (`LambderMSW`); sources organized into core/policies/session/stores/client/shared.
58
- - **Configuration at creation**: `initLambder<SessionData>().create({...})` takes the WHOLE configuration (serving options, session, cors, rate limits, guards, idempotency) in one declaration; the enable/define chain methods are gone, so nothing can be half-configured or wired in the wrong order, and api modules annotate with `typeof lambderApp` derived from the real instance. Plus `LambderCaller.createIdempotencyKeyScope()` for one self-rotating key per logical operation, and fail-open rate limiting logs its passes.
59
-
60
- **Breaking in v4** (from 3.x): configuration moved entirely to creation, removing `enableCors`, `enableDdbSession`, `setSessionCookieKey`, `enableApiRateLimits`, `enableApiIdempotency`, and `defineApiGuards` in favor of the `cors`/`session`/`rateLimits`/`guards`/`idempotency` options of `initLambder().create({...})`; session records are reshaped (hashes at rest; live sessions invalidate once on upgrade, clients just re-login) and the manager-level `createSession`/`regenerateSession` return `LambderCreatedSession` (`{ session, sessionToken, csrfToken }`; the controller API is unchanged); `LambderMSW` moved from the root entry to `lambder/testing`; `LambderCaller.apiRaw()` is removed (use `apiOutcome()`, whose failure outcomes carry the envelope on `response`); the `multiValueHeaders` alias on `res.raw()` is removed (use `headers`); `LambderDdbIdempotency.complete()` answers `"stored" | "too-large" | "lost"`; idempotency keys must be 16-200 chars.
61
-
62
- v3 (public file serving, `addAction()`, gzip + ETag, thrown responses, `LambderTemplatingEngine`, `html`/`xml` tags, payload v2 support, `LambderDdbCache`, `createLambderI18n`, `LambderApiError`/`refuse()`, `apiOutcome()`, the declarative policy foundations) is documented in the git history.
63
-
64
- ## Features
65
-
66
- - **Type-Safe APIs with Zod**: Define inputs and outputs with Zod schemas. Get automatic runtime validation and compile-time type inference.
67
- - **Method Chaining**: Build your API contract incrementally with a fluent interface.
68
- - **Simple API & Route Declaration**: Define your APIs and routes using concise and expressive syntax.
69
- - **Session Management**: Built-in session management to secure and personalize user experiences.
70
- - **Flexible Hooks System**: Employ hooks to execute code at different stages of the request lifecycle.
71
- - **Error Handling**: Comprehensive error handling capabilities, including global error handlers and route-specific fallbacks.
72
- - **Seamless Integration**: Works with API Gateway REST APIs (payload v1), HTTP APIs (payload v2) and Lambda Function URLs; the payload format is detected per event.
73
-
74
- ## Standalone Modules
75
-
76
- Self-contained tools that ship with the package and work with or without the framework. Each has its own guide:
77
-
78
- | Module | Guide | Description |
79
- |---|---|---|
80
- | `html` / `xml` tags + `LambderTemplatingEngine` | [docs/TEMPLATING.md](./docs/TEMPLATING.md) | Type-safe tagged templates and a comment-only HTML template engine (build-pipeline-safe) |
81
- | `LambderDdbCache` | [docs/DDB_CACHE.md](./docs/DDB_CACHE.md) | DynamoDB-backed compressed JSON cache with lease-based single-fill (server-only) |
82
- | `createLambderI18n` | [docs/I18N.md](./docs/I18N.md) | Typed translations with enforced/optional languages, component-level extension and auto language detection (isomorphic) |
83
- | `LambderMSW` | [docs/LAMBDER_MSW.md](./docs/LAMBDER_MSW.md) | Typed MSW mocking of the API contract for frontend development |
84
-
85
- Also see [docs/TYPE_SAFE_QUICK_START.md](./docs/TYPE_SAFE_QUICK_START.md) and [docs/DYNAMODB_SETUP.md](./docs/DYNAMODB_SETUP.md).
86
-
87
- ## Installation
88
-
89
- ```bash
90
- npm install lambder zod
91
- # or
92
- yarn add lambder zod
93
- ```
94
-
95
- `zod` and the AWS SDK clients are optional peer dependencies, so installing
96
- lambder never drags them into your tree. Add whatever the code you actually
97
- import needs:
98
-
99
- | What you import | What to install alongside |
100
- |---|---|
101
- | `lambder/client` (browser, shared isomorphic code) | `zod` |
102
- | `lambder` on AWS Lambda (`nodejs18.x` and later) | `zod`. The runtime already provides the AWS SDK v3, so mark the SDK packages as dev dependencies and keep them out of the deployment package |
103
- | `lambder` anywhere else (a long-running server, a container, local tests) | `zod`, `@aws-sdk/client-dynamodb`, `@aws-sdk/lib-dynamodb` |
104
- | `LambderS3FileSource` | `@aws-sdk/client-s3`, loaded on first read |
105
- | `lambder/testing` | `msw` |
106
-
107
- The SDK and its `@smithy` tree are roughly 21MB installed, which is why they are
108
- peers rather than dependencies: a frontend importing only `lambder/client` has
109
- no use for any of it, and a Lambda deployment package should not ship a second
110
- copy of what the runtime already loads. The runtime pins its own SDK version,
111
- so if you need a specific one, install it and bundle it yourself.
112
-
113
- ## Package Entry Points
114
-
115
- The package ships three entry points; pick by where the code runs:
116
-
117
- | Entry | Runs in | Carries |
118
- |-------|---------|---------|
119
- | `lambder` | Server (Lambda) | The full framework: pipeline, sessions, DDB stores, policies, plus everything from `lambder/client` |
120
- | `lambder/client` | Browser and isomorphic shared code | `LambderCaller`, `LambderApiError`/`refuse`, the API contract and envelope types, `html`/`xml` tagged templates, `createLambderI18n` |
121
- | `lambder/testing` | Dev and test tooling | `LambderMSW`, the MSW adapter that serves your typed contract from mock handlers |
122
-
123
- Frontends and shared isomorphic packages should import from `lambder/client` only; the entry's module graph contains no AWS SDK, Node built-ins, or server pipeline, so the browser boundary is structural rather than left to tree-shaking.
124
-
125
- Source layout mirrors this: `src/core/` (request pipeline), `src/policies/` (declarative rate limits, guards, idempotency), `src/session/`, `src/stores/` (DynamoDB primitives), `src/client/`, and `src/shared/` (isomorphic modules both entries re-export).
126
-
127
- ## Backend Usage
128
-
129
- ### Basic Setup
130
-
131
- The whole configuration is given at creation, in one declaration; only
132
- registration (routes, apis, hooks, `use()`) chains afterwards. `initLambder`
133
- is curried so the session data type is fixed first and everything else
134
- (policy names, guard metadata) is INFERRED from the options; TypeScript type
135
- arguments are all-or-nothing per call, so a plain `new Lambder<SessionData>({...})`
136
- would silently widen the inferred policy types, which is why the curried
137
- creator is the canonical entry.
138
-
139
- ```typescript
140
- import { initLambder, LambderLocalFileSource } from 'lambder';
141
- import { z } from 'zod';
142
- import * as path from 'path';
143
-
144
- interface SessionData { userId: string; }
145
-
146
- const lambder = initLambder<SessionData>().create({
147
- apiPath: "/api",
148
- files: new LambderLocalFileSource({ root: path.resolve(`./public`) }),
149
- session: {
150
- tableName: "website-session",
151
- tableRegion: "us-east-1",
152
- sessionSalt: "CHANGE-THIS-TO-A-SECURE-RANDOM-STRING",
153
- },
154
- // true allows any origin; or configure: { origins: ["https://app.example.com"], credentials: true }
155
- cors: true,
156
- });
157
-
158
- // Define type-safe APIs with Zod schemas
159
- lambder
160
- .addApi("getCompanyPage", {
161
- input: z.object({ companyName: z.string() }),
162
- output: z.object({ id: z.string(), name: z.string(), description: z.string() })
163
- }, async ({ apiPayload }, res) => {
164
- // apiPayload is automatically typed and validated!
165
- const data = await fetchDataSomehow(apiPayload.companyName);
166
- return res.api(data); // Return value is type-checked
167
- })
168
- .addApi("loginUser", {
169
- input: z.object({ email: z.string().email(), password: z.string() }),
170
- output: z.object({ success: z.boolean(), token: z.string().optional() })
171
- }, async (ctx, res) => {
172
- const user = await authenticateUser(ctx.apiPayload.email, ctx.apiPayload.password);
173
- if (!user) {
174
- return res.api({ success: false });
175
- }
176
-
177
- await lambder.getSessionController(ctx).createSession(user.id);
178
- return res.api({ success: true, token: "session-token" });
179
- });
180
-
181
- // Export the inferred contract for the frontend
182
- export type ApiContractType = typeof lambder.ApiContract;
183
-
184
- // Export the handler
185
- export const handler = lambder.getHandler();
186
- ```
187
-
188
- Each contract entry carries the API's `input` and `output`, its `guardInputs` when a guardInput-mode guard applies, and its `guards` option exactly as declared (`ApiContractType["getUser"]["guards"]` is the literal `{ readonly orgPermission: "USERS.MANAGE" }`). A client that keeps its own map of what an API needs, to decide whether to render a screen before calling, pins that map to the declarations with `satisfies` instead of a test that reads the server source:
189
-
190
- ```typescript
191
- type PermissionNeededBy<K extends keyof ApiContractType> =
192
- ApiContractType[K] extends { guards: { orgPermission: infer N } } ? N : never;
193
-
194
- const NEEDS = {
195
- getUser: "USERS.MANAGE",
196
- } as const satisfies { [K in keyof ApiContractType]?: PermissionNeededBy<K> };
197
- ```
198
-
199
- Renaming the permission on the server, or moving the API to a different one, then fails the client's map to compile. Make the mapped type non-optional (over the guarded API names) when the map must also stay complete as guarded APIs are added.
200
-
201
- ### Adding Routes
202
-
203
- ```typescript
204
- lambder
205
- // Define a simple route
206
- .addRoute("/hello-world", (ctx, res) => {
207
- return res.html("Hello World");
208
- })
209
- // Route with parameters
210
- .addRoute("/user/:userId", async (ctx, res) => {
211
- const user = await getUser(ctx.pathParams.userId);
212
- if(!user) return res.status404("Not found");
213
- return res.html(`Hello ${user.name}`);
214
- })
215
- // Define a regex route
216
- .addRoute(/\/hello-regex/, (ctx, res) => {
217
- return res.html("Hello Regex");
218
- })
219
- // Function routes allows routing on any context variable
220
- .addRoute((ctx)=>ctx.path === '/hello-fn-route', (ctx, res) => {
221
- return res.html("Hello from a function route");
222
- })
223
- // Match on method/host with a structured matcher
224
- .addRoute({ path: "/stripe-webhook", method: "POST" }, (ctx, res) => {
225
- return res.json({ received: true });
226
- })
227
- // Serve real files from the files source (see "Public file sources"
228
- // below). This is a terminal fallback slot, NOT a catch-all route, so it
229
- // can never shadow routes registered after it.
230
- .servePublicFiles()
231
- // Serve the app shell for GET/HEAD page requests nothing else handled
232
- // (see "Hosting a frontend build" below).
233
- .serveIndexHtml()
234
- // Set a fallback handler for whatever remains
235
- .setRouteFallbackHandler((ctx, res) => {
236
- return res.status404("Not Found");
237
- })
238
- // Set a fallback handler for unmatched APIs
239
- .setApiFallbackHandler((ctx, res) => {
240
- return res.api(null, { errorMessage: "API not found" });
241
- })
242
- // Handle Zod validation errors for API inputs
243
- .setApiInputValidationErrorHandler((ctx, res, zodError) => {
244
- return res.api(null, { errorMessage: zodError.issues });
245
- })
246
- // Global error handler
247
- .setGlobalErrorHandler((err, ctx, res) => {
248
- console.error("Error:", err);
249
- return res.raw({ statusCode: 500, body: "Internal Server Error" });
250
- });
251
- ```
252
-
253
- ### Session-Protected APIs
254
-
255
- Use `addSessionApi` for endpoints that require authentication:
256
-
257
- ```typescript
258
- lambder.addSessionApi("getProfile", {
259
- input: z.void(),
260
- output: z.object({ userId: z.string(), username: z.string() })
261
- }, async (ctx, res) => {
262
- // Session is automatically fetched and validated
263
- return res.api({
264
- userId: ctx.session.data.userId,
265
- username: ctx.session.data.username
266
- });
267
- });
268
- ```
269
-
270
- ### Modular APIs with .use()
271
-
272
- For larger applications, split your APIs into separate modules:
273
-
274
- ```typescript
275
- // user-api.ts
276
- import { z } from "zod";
277
- import Lambder, { LambderLocalFileSource } from "lambder";
278
-
279
- export const userApi = <T>(l: Lambder<T>) => {
280
- return l
281
- .addApi("getUser", {
282
- input: z.object({ id: z.string() }),
283
- output: z.object({ id: z.string(), name: z.string() })
284
- }, async (ctx, res) => {
285
- return res.api({ id: ctx.apiPayload.id, name: "User" });
286
- })
287
- .addApi("createUser", {
288
- input: z.object({ name: z.string(), email: z.string() }),
289
- output: z.object({ id: z.string() })
290
- }, async (ctx, res) => {
291
- return res.api({ id: "123" });
292
- });
293
- };
294
-
295
- // index.ts
296
- import { userApi } from "./user-api";
297
-
298
- const lambder = new Lambder({ files: new LambderLocalFileSource({ root: './public' }) })
299
- .use(userApi);
300
-
301
- export type ApiContractType = typeof lambder.ApiContract;
302
- ```
303
-
304
-
305
- ### Actions (addAction)
306
-
307
- The same Lambda often also receives non-HTTP invocations: EventBridge/CloudWatch schedules, custom events, SQS batches. `addAction(filter, action)` registers a handler whose filter sees the **raw Lambda event** (always) and the **HTTP context** (`ctx`, or `null` for non-HTTP invocations). `getHandler()` dispatches everything.
308
-
309
- ```typescript
310
- lambder
311
- // Non-HTTP trigger: filter on the raw event (one plain function, no DSL)
312
- .addAction(
313
- (event) => (event as { source?: string })?.source === "app.reconciliation",
314
- async (event, { lambdaContext }) => {
315
- await reconcileEverything();
316
- return { reconciled: true };
317
- },
318
- )
319
- // Type-guard filters give a typed event in the handler
320
- .addAction(
321
- (event): event is ScheduledEvent => isScheduledEvent(event),
322
- async (event) => runMaintenance(),
323
- )
324
- // HTTP interception: ctx is present, and the action must return a response via tools.res
325
- .addAction(
326
- (event, ctx) => ctx !== null && ctx.host.endsWith("dev.example.com") && ctx.cookie.dev !== "atlas",
327
- async (event, { res }) => res!.status404("Not found"),
328
- );
329
-
330
- export const handler = lambder.getHandler();
331
- ```
332
-
333
- Semantics:
334
- - The handler's second argument is `{ ctx, res, lambdaContext }`, discriminated on `ctx`: both `ctx` and `res` are non-null for HTTP invocations and `null` otherwise, so `if (tools.ctx)` narrows both
335
- - **HTTP invocations**: actions join the same first-match chain as routes/APIs (registration order) and must return a response built with `tools.res`
336
- - **Non-HTTP invocations**: actions are the only handlers; return values pass through to Lambda untouched (e.g. `{ batchItemFailures }` for SQS) and errors **rethrow** (never routed to `setGlobalErrorHandler`), preserving Lambda-native retry/DLQ semantics
337
- - A trailing `.addAction(() => true, handler)` acts as the fallback for unmatched non-HTTP events; with no match at all, a descriptive error is thrown
338
-
339
- ### Hooks
340
-
341
- Lambder provides hooks to execute code at different stages of the request lifecycle.
342
-
343
- ```typescript
344
- lambder
345
- // Before render hook
346
- .addHook("beforeRender", async (ctx, res) => {
347
- // Perform actions before rendering
348
- console.log("Request received:", ctx.path);
349
- return ctx; // Return the (modified) ctx to continue, a response to short-circuit, or throw an Error
350
- })
351
- // After render hook
352
- .addHook("afterRender", async (ctx, res, response) => {
353
- // Modify response before sending
354
- console.log("Response status:", response.statusCode);
355
- return response;
356
- })
357
- // Fallback hook - runs when no route/API matches
358
- .addHook("fallback", async (ctx, res) => {
359
- // Perform cleanup or logging for unmatched requests
360
- console.log("No handler matched for:", ctx.path);
361
- });
362
- ```
363
-
364
- ### Session Management
365
-
366
- Enable DynamoDB-based sessions with the `session` option at creation:
367
-
368
- ```typescript
369
- const lambder = initLambder<SessionData>().create({
370
- apiPath: "/api",
371
- session: {
372
- tableName: "website-session",
373
- tableRegion: "us-east-1",
374
- sessionSalt: "CHANGE-THIS-TO-A-SECURE-RANDOM-STRING",
375
- enableSlidingExpiration: true, // Optional: extend session on each access
376
- compression: true, // Optional: Brotli-compress session.data at rest (default true; false to disable, or { minBytes })
377
- // Optionally customize cookie names (defaults: LMDRSESSIONTKID, LMDRSESSIONCSTK)
378
- tokenCookieKey: "MY_SESSION_TOKEN",
379
- csrfCookieKey: "MY_CSRF_TOKEN",
380
- },
381
- });
382
- ```
383
-
384
- #### DynamoDB Session Table Structure
385
-
386
- - Primary Key: "pk"
387
- - Sort Key: "sk"
388
- - TTL Key: "expiresAt" (optional, recommended)
389
-
390
- See [docs/DYNAMODB_SETUP.md](docs/DYNAMODB_SETUP.md) for detailed setup instructions.
391
-
392
- #### Cookie scope
393
-
394
- `session.cookie` sets the scope of the two session cookies: `{ domain: ".example.com" }` shares a login across subdomains, and `domain` may be a `(hostname) => string | undefined` function when one deployment serves several apex domains (return undefined for a host-only cookie); `path`, `sameSite` (default `Lax`) and `secure` (default true) complete it. `LambderCaller` takes the same `sessionCookieDomain` so it can clear the CSRF cookie where the server set it.
395
-
396
- Changing `domain` or `path` on a live deployment is a migration, because a browser identifies a cookie by (name, domain, path): the old copy stays beside the new one, both arrive on every request, and the browser's order says nothing about which is current. The controller handles the overlap: when the session cookie name arrives more than once it tries every copy (record lookup and CSRF pairing per copy), takes the live one, logs the ambiguity, and evicts the stale host-only twin from the response when a domain is configured. The reverse move, from a domain cookie back to host-only, cannot be evicted (this host cannot name the parent domain), so that copy is tolerated on every request until its own expiry. Renaming the cookies (`tokenCookieKey`, `csrfCookieKey`) alongside the scope change avoids the overlap entirely.
397
-
398
- #### How the secrets are stored
399
-
400
- The session cookie is `pkHash:secret`: `pkHash = sha256(sessionKey + sessionSalt)` and `secret` is 256 random bits. At rest the record stores only HASHES of the bearer secrets: the range key is `sha256(secret)` (so the lookup itself proves possession of the raw secret) and the CSRF token is stored as `csrfTokenHash`. The raw values exist only in the client's cookies and, transiently, on the `LambderCreatedSession` result the manager returns at creation; a read of the session table (backup leak, over-broad IAM, insider) therefore yields no usable cookies. Fast sha256 is the correct construction here rather than a password KDF: the secrets are 256-bit random, so there is nothing to brute-force, while `sessionSalt` peppers the identity-to-partition-key mapping so partition keys and cookie prefixes cannot be derived from (or linked to) known user ids.
401
-
402
- #### Keeping session data fresh (`dataRefresh`)
403
-
404
- Session data often caches values derived from external state: roles, permissions, feature flags. Opt in to `dataRefresh` to give that data a shelf life. Every session read checks it, and once `ttlSeconds` have passed your `refresh` callback rebuilds the data, which is persisted onto the same session record: same tokens, same cookies, the session itself is untouched. Changes to the source of truth then reach every live session within `ttlSeconds`, with no mass session invalidation.
405
-
406
- ```typescript
407
- const lambder = initLambder<SessionData>().create({
408
- session: {
409
- tableName: "website-session",
410
- tableRegion: "us-east-1",
411
- sessionSalt: "CHANGE-THIS-TO-A-SECURE-RANDOM-STRING",
412
- dataRefresh: {
413
- ttlSeconds: 600, // data is renewed at most every 10 minutes
414
- refresh: async (session) => {
415
- const user = await loadUser(session.data.userId);
416
- if (!user || user.disabled) return null; // null ends the session
417
- return buildSessionData(user);
418
- },
419
- },
420
- },
421
- });
422
- ```
423
-
424
- Semantics:
425
-
426
- - The callback must be a pure derivation of external state: concurrent reads may run it in parallel, last write wins.
427
- - Returning `null` deletes the session; the request is answered as session-expired.
428
- - Thrown errors fail the request as a `LambderSessionDataRefreshError` and leave the session untouched (they are never mistaken for a logout). Catch inside and return `session.data` to explicitly serve stale instead.
429
- - Similarly, a DynamoDB failure while READING a session fails the request as a `LambderSessionReadError` instead of reading as "no session": answering session-expired there would make LambderCaller clear the client's cookies, turning an infra blip into a forced logout.
430
- - The renewal write and the sliding-expiration write share a single DynamoDB put when both are due.
431
- - Records created before `dataRefresh` was enabled renew on their first read.
432
- - `updateSessionData()` marks data fresh (it was just written deliberately); `regenerateSession()` carries the old freshness stamp over.
433
- - `expireSessionDataAllByKey(sessionKey)` stamps every session of a subject stale at once: call it after changing that subject's roles or permissions, and the change applies on their next request instead of within `ttlSeconds`, with no logout. It updates only `dataExpiresAt`, conditionally on the record still existing, so it neither resurrects a deleted session nor clobbers a concurrent write.
434
-
435
- #### Session data at rest (`compression`)
436
-
437
- `session.data` is stored Brotli-compressed by default: the record carries the data's JSON as Brotli bytes in `dataBr` beside its byte length in `dataBytes`, in place of a plain `data` attribute. It is the scheme `LambderDdbCache` and `LambderDdbIdempotency` already use, from the one shared codec that also restores compressed request payloads, and the byte length both bounds the decompression and verifies it, so a truncated record fails to decode rather than decoding to something else. Session data that caches roles, permissions or product lists typically shrinks 2-3x, which keeps a growing session within one DynamoDB read unit (4KB for the consistent reads sessions use) and one write unit (1KB) for longer.
438
-
439
- ```typescript
440
- session: {
441
- // ...
442
- compression: true, // default: every record compressed, the same as { minBytes: 0 }
443
- // compression: { minBytes: 1024 } compresses only records whose JSON is 1KB+
444
- // compression: false stores data as a plain attribute
445
- }
446
- ```
447
-
448
- `quality` (Brotli 0-11, default 5) is also accepted; the option (`LambderCompressionOption`) is the same one `LambderDdbCache` and `LambderDdbIdempotency` take. Reads accept both record shapes, so the setting can be switched on or off on a live table: records written under the other setting keep reading, and each is rewritten in the current shape on its next write (a sliding-expiration or `dataRefresh` write included). A compressed record that fails to decode is treated like any malformed record: no session.
449
-
450
- #### Session Controller
451
-
452
- Access the session controller with `lambder.getSessionController(ctx)`:
453
-
454
- | Method | Description |
455
- |--------|-------------|
456
- | `createSession(sessionKey, data?, ttlInSeconds?)` | Start new session, persist to DDB |
457
- | `fetchSession()` | Fetch & validate existing session (throws if not found) |
458
- | `fetchSessionIfExists()` | Returns session or null |
459
- | `updateSessionData(newData)` | Update session data in DDB |
460
- | `refreshSessionData()` | Run the `dataRefresh` callback now, regardless of TTL |
461
- | `endSession()` | End session, delete from DDB |
462
- | `endSessionAll()` | End all sessions for this sessionKey (all devices) |
463
- | `deleteSessionAllByKey(sessionKey)` | Delete all sessions of any sessionKey (e.g. "log user X out everywhere") |
464
- | `expireSessionDataAllByKey(sessionKey)` | Mark the data of all sessions of a sessionKey stale, so each renews via `dataRefresh` on its next read (no logout) |
465
- | `regenerateSession()` | Regenerate token (use after password change) |
466
-
467
- ### Type-Safe Templating (html / xml)
468
-
469
- Lambder ships zero-dependency tagged template literals instead of a template engine. Interpolated values are HTML-escaped automatically, and everything is plain TypeScript, so templates are fully type-checked and refactorable. **Full guide: [docs/TEMPLATING.md](./docs/TEMPLATING.md).**
470
-
471
- ```typescript
472
- import { html, xml, raw } from "lambder";
473
-
474
- // Values are escaped by default (XSS-safe); arrays flatten; nested fragments
475
- // are not double-escaped; null/undefined/false render as empty string:
476
- const list = html`<ul>${items.map((item) => html`<li>${item.label}</li>`)}</ul>`;
477
-
478
- // Works for XML too (xml is an alias of html):
479
- return res.xml(xml`<?xml version="1.0" encoding="UTF-8"?>
480
- <urlset>${urls.map((loc) => xml`<url><loc>${loc}</loc></url>`)}</urlset>`);
481
- ```
482
-
483
- ### Templating with LambderTemplatingEngine
484
-
485
- `LambderTemplatingEngine` is a standalone, comment-only HTML template engine. Every construct is an HTML comment, so templates survive HTML build pipelines (e.g. Vite) untouched, and during frontend development the browser simply renders the default content because the markers are invisible. **Full guide: [docs/TEMPLATING.md](./docs/TEMPLATING.md).**
486
-
487
- ```html
488
- <title><!--slot:title-->Default Title<!--/slot:title--></title> <!-- replaceable region -->
489
- <!--slot:head/--> <!-- insert-only point -->
490
- <!--if:isRtl--><body dir="rtl"><!--else--><body><!--/if:isRtl--> <!-- conditional -->
491
- ```
492
-
493
- ```typescript
494
- import { LambderTemplatingEngine, html } from "lambder";
495
-
496
- const template = await LambderTemplatingEngine.fromFile("./templates/page.html");
497
- const output = template.render({
498
- title: userInput, // escaped (XSS-safe)
499
- head: html`<link rel="canonical" href="${canonicalUrl}" />`,
500
- isRtl: lang === "ar",
501
- });
502
- ```
503
-
504
- ### Hosting a frontend build (servePublicFiles + templateFile)
505
-
506
- Lambder has no SPA-specific machinery; hosting a frontend build is a recipe built from three generic primitives: `servePublicFiles()` (terminal slot serving real files: memory-cached, immutable Cache-Control for hashed assets, ETag/gzip, falls through when missing), `serveIndexHtml()` (next fallback slot, GET/HEAD + non-file-path gated) and `res.templateFile()` (render an HTML file through the templating engine, compiled once and cached). **Full guide with the multi-tenant recipe: [docs/TEMPLATING.md](./docs/TEMPLATING.md).**
507
-
508
- #### Public file sources
509
-
510
- The `files` option at creation is a `LambderFileSource`, an object with one method, `read(relativePath)`, returning `{ body, mimeType? }` or `null`. The instance owns one reader over it, `lambder.files`, and `servePublicFiles`, `serveIndexHtml`, `res.file` and `res.templateFile` all go through that reader, which does everything else for every source: traversal check, in-memory file cache for warm invocations (default 32MB, 2MB per file), compiled-template cache, mime fallback from the extension. Cache-Control (immutable for content-hashed names), ETag and compression are applied by the serving slot and the response pipeline. Built in:
511
-
512
- ```typescript
513
- // A folder, typically the build output bundled with the deployment.
514
- initLambder().create({ files: new LambderLocalFileSource({ root: path.resolve("./public") }) });
515
-
516
- // S3. @aws-sdk/client-s3 is an optional peer dependency, loaded on first read.
517
- initLambder().create({
518
- files: new LambderS3FileSource({ bucket: "myapp-web", prefix: "v42/", clientConfig: { region: "eu-central-1" } }),
519
- });
520
-
521
- // Cloudflare R2, or any S3-compatible store: point the client at its endpoint.
522
- initLambder().create({
523
- files: new LambderS3FileSource({
524
- bucket: "myapp-web",
525
- clientConfig: { region: "auto", endpoint: "https://<account>.r2.cloudflarestorage.com", credentials: { accessKeyId, secretAccessKey } },
526
- }),
527
- });
528
-
529
- // Anything else: implement read().
530
- initLambder().create({ files: { read: async (relativePath) => myStore.get(relativePath) } });
531
-
532
- // The in-memory file cache, tuned or off, beside any source.
533
- initLambder().create({ files: { source: new LambderS3FileSource({ bucket: "myapp-web" }), memoryCache: { maxBytes: 64_000_000, maxFileBytes: 4_000_000 } } });
534
- initLambder().create({ files: { source: new LambderLocalFileSource({ root }), memoryCache: false } });
535
- ```
536
-
537
- A missing S3 object reads as null; grant `s3:ListBucket` besides `s3:GetObject`, otherwise S3 answers a missing key with AccessDenied, which propagates as an error instead of falling through. The object's Content-Type is used unless it is a generic octet-stream, in which case the extension decides. Lambda's ~6MB response cap still applies to anything proxied this way: redirect large downloads to the bucket or CDN URL instead of serving them.
538
-
539
- ```typescript
540
- // Zero-config single-tenant hosting:
541
- lambder.servePublicFiles().serveIndexHtml();
542
-
543
- // Templated shell:
544
- lambder.servePublicFiles().serveIndexHtml(async (ctx, res) => {
545
- return res.templateFile("index.html", {
546
- title: pageTitle(ctx),
547
- head: html`<link rel="canonical" href="${canonicalUrl(ctx)}" />`,
548
- }, { cacheControl: "no-cache" });
549
- });
550
- ```
551
-
552
- ### Render Context (ctx) Variables
553
-
554
- The `ctx` object provides access to request data:
555
-
556
- | Property | Description | Example |
557
- |----------|-------------|----------|
558
- | `host` | Request host | `"www.example.com"` |
559
- | `path` | Request path | `"/api"` |
560
- | `pathParams` | Path parameters (routes) | `{ userId: "123" }` |
561
- | `method` | HTTP method | `"GET"`, `"POST"` |
562
- | `get` | Query parameters | `{ page: "1" }` |
563
- | `post` | POST body (parsed) | `{ name: "John" }` |
564
- | `rawBody` | Decoded request body as received (webhook signatures) | `'{"a":1}'` |
565
- | `ip` | Client IP (CF-Connecting-IP / X-Forwarded-For / source IP) | `"1.2.3.4"` |
566
- | `header(name)` | Case-insensitive request header lookup | `ctx.header("accept-language")` |
567
- | `cookie` | Cookies (the first value when a name arrived more than once) | `{ rememberMe: "true" }` |
568
- | `cookieList` | Every value per cookie name, in header order (a name held at several scopes arrives several times) | `{ rememberMe: ["true"] }` |
569
- | `headers` | Request headers | `{ "Content-Type": "..." }` |
570
- | `event` | Raw Lambda event (APIGatewayProxyEvent or APIGatewayProxyEventV2) | - |
571
- | `lambdaContext` | AWS Lambda Context | - |
572
- | `apiName` | API name (for API calls) | `"getUser"` |
573
- | `apiPayload` | Validated input | `{ userId: "123" }` |
574
- | `session` | Session data | Available in `addSessionApi` |
575
-
576
- ### Resolver Methods
577
-
578
- **Header Manipulation** (call before returning response):
579
- - `res.addHeader(key, value)` - Adds a header value (can be called multiple times for same key)
580
- - `res.setHeader(key, value)` - Sets a header (replaces existing values)
581
- - `res.setCookie(name, value, options?)` - Adds a Set-Cookie header. Options: `domain` (a string, or a `(hostname) => string | undefined` function resolved against the request host), `path` (default `/`), `sameSite` (default `Lax`), `secure` (default true), `httpOnly`, `maxAge` (seconds), `expires` (Date), `encode` (default encodeURIComponent, which `ctx.cookie` reverses)
582
- - `res.clearCookie(name, options?)` - Adds a Set-Cookie header that deletes the cookie. Pass the `domain` and `path` it was set with: a cookie's identity is (name, domain, path), so a deletion under another scope deletes nothing
583
- - `res.logToApiResponse(data)` - Adds data to logList in API responses (debugging)
584
-
585
- **Response Methods** (all accept an options object: `{ statusCode?, headers?, cacheControl?, compress?, etag? }`):
586
-
587
- | Method | Description |
588
- |--------|-------------|
589
- | `res.raw(init)` | Custom HTTP response |
590
- | `res.json(data, options?)` | JSON response |
591
- | `res.text(data, options?)` | Plain text response |
592
- | `res.xml(data, options?)` | XML response (accepts xml\`...\` templates) |
593
- | `res.html(data, options?)` | HTML response (accepts html\`...\` templates) |
594
- | `res.status(code, body?, options?)` | Response with any status code |
595
- | `res.redirect(url, statusCode?, options?)` | Redirect (default: 302) |
596
- | `res.status404(data, options?)` | 404 Not Found response |
597
- | `res.fileBase64(base64, mimeType, options?)` | File from base64 content |
598
- | `await res.file(path, options? & { fallback? })` | Serve file from public directory (404 when missing) |
599
- | `await res.templateFile(path, data?, options?)` | Render an HTML file via LambderTemplatingEngine (cached; throws when missing) |
600
- | `res.api(payload, config?, options?)` | Standardized API response |
601
- | `res.apiBinary(payload, config?, options?)` | API response with forced compression |
602
-
603
- Responses are finalized once at the end of the request: automatic compression (when the client accepts it, the body is compressible and large enough), automatic ETag + `If-None-Match` 304 handling on GET/HEAD, and a clear error if the body would exceed Lambda's ~6MB cap. Override per response with `compress: true | false` and `etag: false`.
604
-
605
- The encoding is negotiated against `Accept-Encoding` in the order `compression.encodings` declares, `["br", "gzip"]` by default. Brotli at quality 5 (`compression.quality`) runs at roughly gzip's speed while producing smaller bodies: 15-25% on markup and prose, and substantially more on the repetitive record lists API responses tend to be. Because the ~6MB cap is checked on the FINAL body, that is headroom as well as bandwidth. A client that offers only gzip gets gzip, and `compression: { encodings: ["gzip"] }` turns Brotli off entirely for a CDN or client that mishandles it. `Vary: Accept-Encoding` rides every compressible response, whether or not this particular client accepted an encoding, so shared caches stay correct.
606
-
607
- ```typescript
608
- initLambder().create({
609
- compression: { minBytes: 860, encodings: ["br", "gzip"], quality: 5 }, // the defaults
610
- // compression: false, // no automatic compression at all
611
- });
612
- ```
613
-
614
- **API Config Options**: `{ notAuthorized, message, errorMessage, versionExpired, sessionExpired, logList }`
615
-
616
- **Die Methods**: `res.die.*` - Builds the response and throws it, immediately halting the request at any call depth (handlers, hooks, nested helper functions). Plain `throw res.html(...)` works the same way.
617
-
618
- ### Typed API Refusals (refuse / LambderApiError)
619
-
620
- A refusal ("you are not allowed", "quota exceeded") is not a crash. `res.die.*` covers refusals where you hold the resolver, but shared helpers (permission checks, validators) usually don't. The one-liner for the common case is `refuse()`: callable from anywhere in an API call's stack, it throws a typed refusal carrying the standard `LambderRefusalMessage` shape (`{ type, code?, title?, content }`) that the pipeline maps onto the envelope's `errorMessage`, so refusals never pollute crash logging and clients get a parseable response:
621
-
622
- ```typescript
623
- import { refuse } from "lambder";
624
-
625
- if (!row) refuse("Record not found."); // { type: "warning", content }
626
- if (!isAdmin) refuse("Admins only.", { notAuthorized: true }); // + envelope flag
627
- refuse("Too many attempts.", { type: "error", statusCode: 429 }); // custom rendering intent + status
628
- if (exists) refuse("Already reported.", { code: "ALREADY_REPORTED" }); // + machine-readable identity
629
- // TypeScript applies never-return narrowing: after `if (!row) refuse(...)`, row is defined.
630
- ```
631
-
632
- `code` is the refusal's identity for machines: clients branch and translate on it (a translated client never displays `content`, it looks the code up), and `content` stays the human-readable fallback for codes a client does not know yet. Keep your app's codes as one typed vocabulary in shared code. The framework stamps the refusals it authors itself with `LAMBDER_REFUSAL_CODES` (exported from `lambder` and `lambder/client`) under the reserved `lambder/` prefix, so app codes never collide: `rateLimited`, `duplicateInFlight`, `invalidIdempotencyKey`, `apiNotFound`. A rate-limit policy's own `errorMessage` inherits `lambder/rate-limited` unless it sets a code, so an `errorMessageHandler` can treat every rate limit alike and still special-case the ones you name.
633
-
634
- For full control of the errorMessage payload (apps with their own message vocabulary), throw `LambderApiError` directly; `refuse()` is sugar over it:
635
-
636
- ```typescript
637
- import { LambderApiError } from "lambder";
638
-
639
- // In any helper, no resolver needed:
640
- export const requirePermission = (granted: boolean) => {
641
- if (!granted) throw new LambderApiError("Permission denied.", {
642
- notAuthorized: true, // envelope flag -> caller's notAuthorizedHandler
643
- errorMessage: { type: "warning", content: "Not allowed." }, // any shape your errorMessageHandler expects
644
- // sessionExpired: true, // optional envelope flag
645
- // statusCode: 403, // optional; default 200 (avoid 5xx and 422)
646
- });
647
- };
648
- ```
649
-
650
- `errorMessage` defaults to the error's message string, so `throw new LambderApiError("Nope.")` alone is already visible to the client. Thrown outside an API call (e.g. in a route handler) it behaves like a normal error. The class is isomorphic and dependency-free, so shared server/browser packages can import it safely. Detection is brand-based (`isLambderApiError`), so it works even when two copies of lambder end up in one bundle.
651
-
652
- Related: when an API call crashes with no `setGlobalErrorHandler` (or the handler itself fails), the last-resort 500 is now a JSON envelope (`{ payload: null, errorMessage: "Internal server error." }`) instead of a plain-text page; routes keep the plain-text 500.
653
-
654
- ### Declarative API Policies (rate limits, guards, idempotency)
655
-
656
- Declare named building blocks once; reference them from API definitions with full type inference (unknown names are compile errors, and everything is re-asserted at registration time for plain-JS safety). Each piece is independent and optional.
657
-
658
- ```typescript
659
- import { initLambder, LambderDdbRateLimiter, LambderDdbIdempotency, lambderGuard, lambderRateLimitKey, refuse } from "lambder";
660
-
661
- const lambder = initLambder<SessionData>().create({
662
- apiPath: "/api",
663
- // 1. Rate limiting: your limiter instance + named policies. Each policy
664
- // declares its windows, what one counter tracks ("per"), and what one
665
- // budget spans ("budget"): "perApi" (default) gives every referencing
666
- // API its own counter, so three APIs on a 60/min policy allow one IP
667
- // 180/min in total; "perPolicy" makes every referencing API share ONE
668
- // counter. The policy IS the group: separate shared budgets for, say,
669
- // user APIs and report APIs are two policies.
670
- rateLimits: {
671
- limiter: new LambderDdbRateLimiter({ tableName: "app-rate-limiter", region: "us-east-1", failOpen: true }),
672
- policies: {
673
- authPerIp: { perMin: 5, perHour: 30, per: "ip" },
674
- writePerUser: { perMin: 30, per: "session" }, // only referable from addSessionApi (also enforced at compile time)
675
- codePerEmail: {
676
- perMin: 3,
677
- // ONE combined budget across every API that references this
678
- // policy: send + register + reset share the 3/min.
679
- budget: "perPolicy",
680
- // apiInput key: derives from the API's OWN payload. Validated
681
- // before it runs, typed in the handler, and the policy is only
682
- // referable from APIs whose input schema carries `email`.
683
- per: lambderRateLimitKey({
684
- apiInput: z.object({ email: z.string() }),
685
- handler: (_ctx, { email }) => email.trim().toLowerCase(),
686
- }),
687
- errorMessage: { type: "warning", content: "Too many attempts for this address." },
688
- },
689
- },
690
- },
691
- // 2. Idempotency: a store instance + replay defaults. May share the rate
692
- // limiter's table (records use an IDEM# key prefix).
693
- idempotency: {
694
- store: new LambderDdbIdempotency({ tableName: "app-rate-limiter", region: "us-east-1" }),
695
- defaultTtlSeconds: 24 * 3600,
696
- failOpen: true, // DynamoDB down => execute without dedupe instead of failing
697
- },
698
- // 3. Named guards. Input modes: apiInput checks a slice of the API's own
699
- // payload (the schema keeps the field; the guard is declarable only
700
- // where the payload type passes both); guardInput is the guard's OWN
701
- // value, sent separately by the caller via options.guardInputs and
702
- // made mandatory by the contract, so forgetting it is a compile error
703
- // at the call site; or neither. Both are validated pre-run and typed
704
- // in the handler. On top of that a guard may require a session
705
- // (session: true, declarable only on addSessionApi), take a per-API
706
- // PARAM (annotate a 4th handler argument), and RETURN a value that
707
- // lands typed on the API handler's ctx.guardData[name].
708
- guards: {
709
- captcha: lambderGuard({
710
- guardInput: z.object({ captchaToken: z.string() }),
711
- handler: async (ctx, { captchaToken }) => {
712
- if (!await verifyCaptcha(captchaToken, ctx.ip)) refuse("Verification failed, please retry.");
713
- },
714
- }),
715
- deviceAuth: lambderGuard({
716
- apiInput: z.object({ deviceToken: z.string() }),
717
- // Returns a value: the API handler reads ctx.guardData.deviceAuth.
718
- handler: async (_ctx, { deviceToken }) => await resolveDeviceOrRefuse(deviceToken),
719
- }),
720
- orgPermission: lambderGuard({
721
- session: true,
722
- // Parameterized: APIs declare guards: { orgPermission: "SOME.PERMISSION" }.
723
- handler: (ctx, _payload, _res, permission: PermissionString) =>
724
- requirePermissionOrRefuse(ctx.session, permission), // return value → ctx.guardData.orgPermission
725
- }),
726
- },
727
- });
728
-
729
- lambder.addApi("public.resetPassword", {
730
- // captchaToken is NOT declared here: it travels in the separate
731
- // guardInputs channel, so the guard validates and consumes it and the
732
- // handler never sees it. `email` IS declared: the codePerEmail key runs
733
- // in apiInput mode against the API's own payload.
734
- input: z.object({ email: z.string().email() }),
735
- output: z.object({ ok: z.boolean() }),
736
- rateLimit: ["authPerIp", "codePerEmail"], // stacked: checked in order, first exceeded refuses (429 envelope + Retry-After)
737
- guards: "captcha", // one name, a non-empty list of names, or a non-empty { name: param } map
738
- }, handler);
739
-
740
- lambder.addSessionApi("secure.order.create", {
741
- input: OrderSchema,
742
- output: OrderResultSchema,
743
- // Map form: tune a perApi policy for this API. Overrides merge over the
744
- // policy's windows (perMin here, the policy's other windows still apply)
745
- // and errorMessage is overridable too. Window overrides on a perPolicy
746
- // policy are a startup error: one shared counter has one set of limits.
747
- rateLimit: { writePerUser: { perMin: 10 } },
748
- guards: { orgPermission: "ORDERS.CREATE" }, // param typed per guard; entries run in insertion order
749
- idempotency: true, // or { ttlSeconds: 3600 }; type error unless created with idempotency
750
- }, async (ctx, res) => {
751
- const { organizationId } = ctx.guardData.orgPermission; // typed guard output
752
- // ...
753
- });
754
- ```
755
-
756
- Guard results are typed end to end: the handler's `ctx.guardData` carries exactly the declared guards that return a value, a session guard on a public API is a compile error (and a startup assert), an apiInput guard is declarable only where the API's schema carries its fields, and a parameterized guard's param is typechecked in the declaration.
757
-
758
- **Requiring an authorization declaration (`requireSessionApiGuards`)**: by default a session API may declare no guards, which reads as "any signed-in user". Once an app has an authorization vocabulary, that silence is where defects hide: the guard exists, a new endpoint forgets it, and nothing notices. With `requireSessionApiGuards: true` at creation, `guards` becomes a required field of every `addSessionApi`: omitting it is a compile error at the registration site ("Property 'guards' is missing"), and a plain-JS registration throws. Public APIs are unaffected. An API that legitimately needs no authorization beyond the session (the signed-in user's own account, a log-out) declares a named no-op session guard, so the opt-out is explicit, greppable, and cannot be used on a public API:
759
-
760
- ```typescript
761
- const lambder = initLambder<SessionData>().create({
762
- apiPath: "/api",
763
- guards: {
764
- orgPermission: lambderGuard({ session: true, handler: (ctx, _p, _r, permission: PermissionString) => requireOrRefuse(ctx.session, permission) }),
765
- // The one opt-out: the session itself is the whole authorization.
766
- sessionOnly: lambderGuard({ session: true, handler: () => {} }),
767
- },
768
- requireSessionApiGuards: true,
769
- });
770
-
771
- lambder.addSessionApi("secure.order.create", { input, output, guards: { orgPermission: "ORDERS.CREATE" } }, handler);
772
- lambder.addSessionApi("secure.me.logOut", { input, output, guards: "sessionOnly" }, handler);
773
- lambder.addSessionApi("secure.report.list", { input, output }, handler); // compile error: which guard?
774
- lambder.addSessionApi("secure.report.list", { input, output, guards: {} }, handler); // compile error: {} declares no guard
775
- ```
776
-
777
- **The same for public APIs (`requirePublicApiGuards`)**: public APIs are open by default, and that remains the default. An app whose public surface has grown past a handful of endpoints can turn `requirePublicApiGuards: true` on to make each one's openness a written decision instead of an omission. Not every public endpoint has a control that can be hoisted into a guard (an endpoint that checks a password *is* the check), so the vocabulary an app declares here is usually a real guard for what is a genuine precondition, plus named no-op guards for the rest. The two flags are independent; either or both may be on.
778
-
779
- ```typescript
780
- const lambder = initLambder<SessionData>().create({
781
- apiPath: "/api",
782
- guards: {
783
- deviceToken: lambderGuard({ apiInput: z.object({ deviceToken: z.string().min(20) }), handler: (_c, { deviceToken }) => requireDevice(deviceToken) }),
784
- // Anyone may call, and the param records why: `grep "open:"` lists every public door.
785
- open: lambderGuard({ handler: (_c, _p, _r, _reason: string) => {} }),
786
- // This endpoint establishes identity; the proof is the handler's own work.
787
- credentialFlow: lambderGuard({ handler: () => {} }),
788
- },
789
- requirePublicApiGuards: true,
790
- });
791
-
792
- lambder.addApi("public.device.report", { input, output, guards: "deviceToken" }, handler);
793
- lambder.addApi("public.translations", { input, output, guards: { open: "Static strings already in the bundle." } }, handler);
794
- lambder.addApi("public.login", { input, output, guards: "credentialFlow" }, handler);
795
- lambder.addApi("public.search", { input, output }, handler); // compile error: open to anyone, or authorized how?
796
- ```
797
-
798
- For api modules split across files, DERIVE the annotation type from the real instance instead of writing it by hand: create the instance next to the policy declarations and export `typeof` it. The type can never drift from what actually runs, and modules import it without a cycle (the app file imports no modules):
799
-
800
- ```typescript
801
- // app.ts: declarations + the fully configured instance
802
- export const lambderApp = initLambder<SessionData>().create({
803
- apiPath: "/api",
804
- session: { tableName: "app-session", tableRegion: "us-east-1", sessionSalt: "..." },
805
- rateLimits: { limiter, policies: apiRateLimitPolicies },
806
- idempotency: { store: idempotencyStore },
807
- guards: apiGuards,
808
- });
809
- export type AppLambder = typeof lambderApp;
810
-
811
- // orders.ts: an api module
812
- export const orderApi = (lambder: AppLambder) => lambder.addSessionApi(...);
813
-
814
- // index.ts: registration only (hooks, routes, modules)
815
- const lambder = lambderApp.addHook(...).use(orderApi)...;
816
- export const handler = lambder.getHandler();
817
- ```
818
-
819
- Request flow per API: session (session APIs) → idempotency replay lookup → rate limits → guards → zod validation → idempotency claim → handler → idempotency store. The replay lookup runs first on purpose: a completed idempotent request answers its stored response without burning rate-limit quota or re-running guards (the original already passed them, and no handler executes either way). Refusals ride the envelope via `LambderApiError` (429 rate limited, 409 duplicate in flight), carrying the standard `LambderRefusalMessage` shape unless a policy names its own `errorMessage`, so the caller's `errorMessageHandler` surfaces them with zero client code. A 429 also carries `Retry-After` (the exceeded fixed window's reset; `LambderCaller` outcomes expose it as `retryAfterSeconds`, and the CORS layer lists it in `Access-Control-Expose-Headers` by default).
820
-
821
- **Rate limits count attempts, not successes.** Each window is one atomic conditional increment, and a refused request keeps every increment made before the refusal: the smaller windows of the refusing policy, every policy listed before it, and all of them when a later guard or the input validation refuses. There is no compensating decrement (it would give up the conditional-ADD atomicity and add a write per refusal). So order stacked policies by which counter you want charged on refusals: `["authPerIp", "codePerEmail"]` still charges the IP when the per-email cap refuses, which is the abuse-resistant direction.
822
-
823
- Preflight input slices (guard `apiInput`/`guardInput` values, rate-limit `apiInput` keys) answer a rejection through the same path as the API's own schema: `setApiInputValidationErrorHandler` when set, otherwise the standard 422 body. One failure, one shape, whichever schema rejected it.
824
-
825
- **Idempotency semantics**: the client sends an `idempotencyKey` per call (see LambderCaller below); generate it once per logical operation with `LambderCaller.createIdempotencyKey()` and reuse it on retries. Keys must be 16-200 characters and UNGUESSABLE random (shorter keys refuse with 400): on session APIs the scope is session + API name + key, and on public APIs it is the key itself + API name, deliberately NOT the client IP, because the retry idempotency exists for (a timeout followed by a network switch) frequently arrives from a new IP. Concurrent duplicates of an in-flight request refuse with 409, repeats of a completed one replay the stored response verbatim until the TTL (response headers included, so headers set via `res.setHeader`/`res.addHeader` replay too), and a crashed original releases its claim so a retry actually retries. The replay rule for failures: RESPONSES are stored and replayed, refusals returned as envelopes (`res.api(null, { errorMessage })`) and thrown responses (`res.die.*`) included; EXCEPTIONS are not, so a thrown `LambderApiError`/`refuse()` releases the claim and a retry re-executes and decides afresh. Stored bodies of 1KB or more are Brotli-compressed by default (the same scheme and `compression` option as LambderDdbCache: `true`, `false`, or `{ minBytes, quality }`, default `{ minBytes: 1024, quality: 5 }`; records of either shape read back, so it can be switched on a live table): JSON envelopes typically shrink 5-10x, which cuts DynamoDB write cost, and the ~350KB item budget applies to the COMPRESSED bytes, so even large responses usually stay replayable. Responses with status ≥ 500, bodies over the budget even compressed, and responses that set cookies are never stored (replaying one request's Set-Cookie, e.g. session tokens, into another would be wrong; such APIs still get in-flight 409 dedupe, just not replays). Claims are owner-checked, so an original that stalls past the pending window can no longer overwrite or delete the claim a retry has since taken. Requests without a key execute normally.
826
-
827
- Also enforced at registration: **duplicate API names throw** (dispatch is first-match, so a second registration of the same name would be silently dead code).
828
-
829
- ### DynamoDB Cache (LambderDdbCache)
830
-
831
- Standalone, persistent JSON cache backed by a DynamoDB table (`pk`/`sk` keys + `expiresAt` TTL attribute, same shape as the session table). Items are prefixed `CACHE#<namespace>#`, and the rate limiter (`RL#`) and idempotency store (`IDEM#`) prefix theirs too, so all three non-session systems can share one table without collisions; keep sessions in their own table for IAM scoping. Brotli-compressed values (the shared `compression` option), in-memory LRU layer, single-flight deduplication, a DynamoDB lease so only one Lambda fills a missing key, fail-open semantics, and optional grouped keys for group invalidation. Server-only. **Full guide with table setup: [docs/DDB_CACHE.md](./docs/DDB_CACHE.md).**
832
-
833
- ```typescript
834
- import { LambderDdbCache } from "lambder";
835
-
836
- const cache = new LambderDdbCache({
837
- tableName: "myapp-cache",
838
- region: "us-east-1",
839
- namespace: "geo", // isolates keys per domain
840
- defaultTtlSeconds: 24 * 3600,
841
- });
842
-
843
- const city = await cache.getOrSet(`city:${slug}`, async () => fetchCityFromDb(slug), {
844
- ttlSeconds: 7 * 24 * 3600,
845
- });
846
- // Also: cache.get(key), cache.set(key, value, { ttlSeconds }), cache.has(key), cache.delete(key)
847
-
848
- // A key can also be a { pk, sk } pair, which groups related entries under one
849
- // partition so the whole group can be invalidated without listing its members:
850
- const window = { pk: `division:${divisionId}`, sk: `${from}:${to}` };
851
- await cache.getOrSet(window, () => loadDivision(divisionId, from, to));
852
- await cache.deletePartition(`division:${divisionId}`); // every cached window of it
853
- await cache.listSortKeys(`division:${divisionId}`); // ["1700:1800", "1700:1900"]
854
- ```
855
-
856
- ### Typed Translations (createLambderI18n)
857
-
858
- Standalone, framework-free i18n with a compile-time contract: keys and `{token}` params are inferred from the default-language dictionary, components extend the base keys with their own (strictly, or partially with fallback), and the active language resolves automatically (custom detector → browser languages → default). **Full guide: [docs/I18N.md](./docs/I18N.md).**
859
-
860
- ```typescript
861
- import { createLambderI18n } from "lambder";
862
-
863
- export const i18n = createLambderI18n({
864
- languages: { en: { name: "English" }, tr: { name: "Türkçe" }, de: { name: "Deutsch" } },
865
- defaultLanguage: "en",
866
- enforced: ["en"], // languages every dictionary must provide
867
- base: { // strict: all languages, all keys
868
- en: { greet: "Hello {name}" },
869
- tr: { greet: "Merhaba {name}" },
870
- de: { greet: "Hallo {name}" },
871
- },
872
- });
873
-
874
- // componentA.ts — only enforced languages required; de falls back to en:
875
- const cI18n = i18n.extendPartial({ en: { compute: "Compute" }, tr: { compute: "Hesapla" } });
876
- cI18n.t("compute"); // auto-resolved language
877
- cI18n.t("greet", { name: "Ada" }); // base keys + params, compile-time enforced
878
- cI18n.forLanguage("tr")("compute"); // explicit (per-request backend use)
879
- ```
880
-
881
- ## Frontend Usage with LambderCaller
882
-
883
- LambderCaller is a frontend companion library for Lambder (only 2kb compressed) designed to simplify making type-safe API requests to your Lambder backend. Import it from the `lambder/client` entry: everything reachable from there is browser-safe by construction (no AWS SDK, no Node built-ins, no server pipeline), so your bundle can never pick up server code.
884
-
885
- ### Basic Setup with Type Safety
886
-
887
- ```typescript
888
- import { LambderCaller } from "lambder/client";
889
- import type { ApiContractType } from "./backend/handler"; // Import the inferred contract type
890
-
891
- const lambderCaller = new LambderCaller<ApiContractType>({
892
- apiPath: "/api",
893
- isCorsEnabled: false,
894
- fetchStartedHandler: ({ fetchParams, activeFetchList }) => {
895
- console.log("API Called:", fetchParams.apiName);
896
- },
897
- fetchEndedHandler: ({ fetchParams, fetchResult, activeFetchList }) => {
898
- console.log("Ongoing calls:", activeFetchList.length);
899
- },
900
- errorMessageHandler: (message) => {
901
- console.error("LambderCaller:", message);
902
- },
903
- });
904
-
905
- // Fully typed API calls!
906
- const user = await lambderCaller.api("getCompanyPage", { companyName: "Acme" });
907
- // TypeScript knows:
908
- // - Available API names (autocomplete)
909
- // - Required input type
910
- // - Expected output type
911
- ```
912
-
913
- ### Compressed Request Payloads
914
-
915
- Large payloads run into Lambda's ~6MB invoke payload cap long before the API Gateway limit, and the cap applies to what the gateway hands the function. `requestCompression` gzips the payload of any call whose JSON reaches the threshold, so that budget holds the compressed bytes instead of the raw ones:
916
-
917
- ```typescript
918
- const lambderCaller = new LambderCaller<ApiContractType>({
919
- apiPath: "/api",
920
- isCorsEnabled: false,
921
- requestCompression: true, // { minBytes: 4096 }
922
- // requestCompression: { minBytes: 64_000 }, // only genuinely large calls
923
- });
924
-
925
- // Nothing at the call sites changes; this one goes compressed, that one plain.
926
- await lambderCaller.api("importStops", { stops: bigArray });
927
- await lambderCaller.api("getStop", { id: "42" });
928
-
929
- // Per call, either way:
930
- await lambderCaller.api("importStops", huge, { compressRequest: false });
931
- ```
932
-
933
- A compressed call sends `payloadGz` (gzip bytes, base64) beside `payloadBytes` (the JSON's UTF-8 byte length) in place of `payload`. It is only sent when it is smaller than the JSON it replaces: a payload that is mostly a base64 image gzips to nearly its own size, and such a call goes plain rather than slightly larger. Everything else in the envelope stays plain text, so `apiName` routing, request logs and MSW mocks are unaffected, and the request stays `application/json`: no `Content-Encoding` negotiation for a gateway, CDN or proxy to get wrong, and no new CORS preflight surface. Base64 inside the JSON rather than a binary body is not a compromise for the size cap, because API Gateway hands a binary request body to Lambda base64-encoded anyway; base64's 4/3 overhead applies to bytes that already shrank several times over. Record-shaped JSON typically gzips 5-10x, so a ~5MB budget of compressed payload carries roughly 25-40MB of it.
934
-
935
- The option is off by default and safe to turn on or off at any time: the server understands both shapes regardless, so a deployed client and server never need to agree. gzip rather than Brotli because the browser's `CompressionStream` offers gzip and deflate only; responses, compressed by Node, do prefer Brotli. A runtime without `CompressionStream` sends payloads plainly.
936
-
937
- **Server side**: nothing to enable. The payload is restored before rate-limit key slices, guards and input validation run, so handlers, schemas and policies see an ordinary payload and need no awareness of the wire format. `payloadBytes` both bounds the decompression and verifies it (the restored length must match exactly), so a truncated or hostile body is refused rather than expanded, and `maxRequestPayloadBytes` at creation (default 20,000,000) caps what any body may expand to. Size that ceiling to the function's memory: the restored JSON is parsed in full before any session or policy check, and a parsed document occupies several times its text size on the heap. Every malformed case answers a 400 envelope coded `lambder/invalid-request-payload` instead of a 500.
938
-
939
- ```typescript
940
- initLambder().create({ apiPath: "/api", maxRequestPayloadBytes: 20_000_000 });
941
- ```
942
-
943
- Compression moves the ceiling rather than removing it. Past roughly 25-40MB of JSON the answer is a presigned S3 upload plus a job reference, or chunking, not a better codec.
944
-
945
- ### Failure Semantics (apiOutcome, timeouts, per-call overrides)
946
-
947
- `api()` collapses every failure to `null`, which is indistinguishable from a legitimately-null payload. When the call site needs to know why, use `apiOutcome()`; it never throws and resolves to a discriminated union:
948
-
949
- ```typescript
950
- const outcome = await lambderCaller.apiOutcome("getCompanyPage", { companyName: "Acme" });
951
- if (outcome.ok) {
952
- render(outcome.payload);
953
- } else if (outcome.reason === "network" || outcome.reason === "timeout") {
954
- showOfflineScreen();
955
- } else if (outcome.reason === "sessionExpired") {
956
- redirectToLogin();
957
- } else {
958
- // 'server' (5xx / non-envelope body), 'validation' (422), 'versionExpired',
959
- // 'notAuthorized', 'errorMessage' (structured refusal), 'unknown'
960
- showError(outcome.errorMessage);
961
- }
962
- ```
963
-
964
- Every configured handler still fires on the matching failure, so global UX (toasts, re-login prompts) lives in the constructor while individual call sites branch on the outcome.
965
-
966
- Also available:
967
-
968
- - **Timeouts**: pass `timeoutMs` in the constructor for a default (API Gateway caps around 29s, so ~30000 is sensible) and/or per call; timed-out calls abort the fetch and report `reason: 'timeout'`. A per-call `signal` combines with the timeout.
969
- - **Per-call handler overrides**: every constructor handler (`errorHandler`, `sessionExpiredHandler`, `errorMessageHandler`, ...) can be overridden in the options of a single `api`/`apiOutcome` call.
970
- - **Guard inputs**: for APIs whose guards run in guardInput mode, pass their values per call as `guardInputs: { <guardName>: value }`; the typed contract makes the options argument (and the correct value shape) mandatory for those APIs. A `guardInputsProvider` on the caller supplies values for every call from one place, keyed by guard name, with per-call `guardInputs` merged on top; name the guards it covers in the caller's second type parameter, `new LambderCaller<Contract, "orgPermission">({ guardInputsProvider: () => ({ orgPermission: { orgSlug } }), ... })`, and calls to APIs whose guardInput guards are all covered take an optional options argument again.
971
- - **Idempotency keys**: pass `idempotencyKey` per call for APIs declared idempotent on the server (see Declarative API Policies). Generate it once per logical operation with `LambderCaller.createIdempotencyKey()` (safe in insecure contexts where `crypto.randomUUID` is missing) and send the same key on retries; rotate after a confirmed success. `LambderCaller.createIdempotencyKeyScope()` packages that pattern for a component performing one operation repeatedly: read `scope.current` on every attempt, call `scope.rotate()` after a confirmed success. Keys must be unguessable random and 16-200 characters (they scope the replay record for logged-out clients); the server refuses shorter keys with a 400.
972
-
973
- ### Benefits
974
-
975
- ✅ **No Manual Type Definitions** - Types are inferred from your Zod schemas
976
- ✅ **Single Source of Truth** - API contract comes from your backend code
977
- ✅ **Runtime Validation** - Zod validates inputs automatically
978
- ✅ **Compile-Time Safety** - TypeScript catches errors before runtime
979
- ✅ **Autocomplete** - IDE suggests available APIs as you type
980
- ✅ **Zero Overhead** - Type-only imports, no runtime code bloat
981
-
982
- 📖 **[Read the Quick Start Guide](docs/TYPE_SAFE_QUICK_START.md)** for more details and examples!
983
-
984
- ## Testing with LambderMSW
985
-
986
- LambderMSW provides seamless integration with [MSW (Mock Service Worker)](https://mswjs.io/) for testing your APIs with full type safety.
987
-
988
- ```typescript
989
- import { LambderMSW } from 'lambder/testing';
990
- import { setupServer } from 'msw/node';
991
- import type { ApiContractType } from './backend/handler';
992
-
993
- const lambderMSW = new LambderMSW<ApiContractType>({
994
- apiPath: '/api',
995
- msw: await import('msw'),
996
- });
997
-
998
- const handlers = [
999
- // Mock API with full type safety! ✨
1000
- lambderMSW.mockApi('getUser', async (payload) => {
1001
- // payload is typed based on your Zod schema
1002
- return {
1003
- id: payload.userId,
1004
- name: 'John Doe',
1005
- email: 'john@example.com'
1006
- };
1007
- }),
1008
-
1009
- // Simulate delays and custom responses
1010
- lambderMSW.mockApi('createUser', async (payload) => {
1011
- return { id: '123', name: payload.name, email: payload.email };
1012
- }, {
1013
- delay: 500,
1014
- message: 'User created successfully'
1015
- }),
1016
-
1017
- // Mock session expired
1018
- lambderMSW.mockSessionExpired('protectedApi'),
1019
- ];
1020
-
1021
- const server = setupServer(...handlers);
1022
- ```
1023
-
1024
- 📖 **[Read the LambderMSW Guide](docs/LAMBDER_MSW.md)** for complete testing documentation!
1025
-
1026
- ## Contributing
1027
-
1028
- Contributions are welcome! Especially for documentation. If you have an idea for an improvement or have found a bug, please open an issue or submit a pull request.
1029
-
1030
- ## License
1031
-
1032
- This project is licensed under the [MIT License](License.md).