lambder 3.3.3 → 3.5.1

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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
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
4
 
5
- **New in v3:** Public file serving with `servePublicFiles()` + `serveIndexHtml()`, unified `addAction()` for non-HTTP triggers, automatic gzip + ETag, thrown responses with a real `die`, the comment-based `LambderTemplatingEngine`, type-safe `html`/`xml` tagged templates, API Gateway HTTP API (payload v2) / Lambda Function URL support, the `LambderDdbCache` DynamoDB cache (3.1) and typed translations with `createLambderI18n` (3.2).
5
+ **New in v3:** Public file serving with `servePublicFiles()` + `serveIndexHtml()`, unified `addAction()` for non-HTTP triggers, automatic gzip + ETag, thrown responses with a real `die`, the comment-based `LambderTemplatingEngine`, type-safe `html`/`xml` tagged templates, API Gateway HTTP API (payload v2) / Lambda Function URL support, the `LambderDdbCache` DynamoDB cache (3.1), typed translations with `createLambderI18n` (3.2), and in 3.5: typed API refusals with `LambderApiError`, caller outcomes/timeouts with `apiOutcome()`, plus declarative per-API rate limits, guards, and idempotency.
6
6
 
7
7
  ## Features
8
8
 
@@ -275,6 +275,35 @@ lambder
275
275
 
276
276
  See [docs/DYNAMODB_SETUP.md](docs/DYNAMODB_SETUP.md) for detailed setup instructions.
277
277
 
278
+ #### Keeping session data fresh (`dataRefresh`)
279
+
280
+ 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.
281
+
282
+ ```typescript
283
+ lambder.enableDdbSession({
284
+ tableName: "website-session",
285
+ tableRegion: "us-east-1",
286
+ sessionSalt: "CHANGE-THIS-TO-A-SECURE-RANDOM-STRING",
287
+ dataRefresh: {
288
+ ttlSeconds: 600, // data is renewed at most every 10 minutes
289
+ refresh: async (session) => {
290
+ const user = await loadUser(session.data.userId);
291
+ if (!user || user.disabled) return null; // null ends the session
292
+ return buildSessionData(user);
293
+ },
294
+ },
295
+ });
296
+ ```
297
+
298
+ Semantics:
299
+
300
+ - The callback must be a pure derivation of external state: concurrent reads may run it in parallel, last write wins.
301
+ - Returning `null` deletes the session; the request is answered as session-expired.
302
+ - 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.
303
+ - The renewal write and the sliding-expiration write share a single DynamoDB put when both are due.
304
+ - Records created before `dataRefresh` was enabled renew on their first read.
305
+ - `updateSessionData()` marks data fresh (it was just written deliberately); `regenerateSession()` carries the old freshness stamp over.
306
+
278
307
  #### Session Controller
279
308
 
280
309
  Access the session controller with `lambder.getSessionController(ctx)`:
@@ -285,8 +314,10 @@ Access the session controller with `lambder.getSessionController(ctx)`:
285
314
  | `fetchSession()` | Fetch & validate existing session (throws if not found) |
286
315
  | `fetchSessionIfExists()` | Returns session or null |
287
316
  | `updateSessionData(newData)` | Update session data in DDB |
317
+ | `refreshSessionData()` | Run the `dataRefresh` callback now, regardless of TTL |
288
318
  | `endSession()` | End session, delete from DDB |
289
319
  | `endSessionAll()` | End all sessions for this sessionKey (all devices) |
320
+ | `deleteSessionAllByKey(sessionKey)` | Delete all sessions of any sessionKey (e.g. "log user X out everywhere") |
290
321
  | `regenerateSession()` | Regenerate token (use after password change) |
291
322
 
292
323
  ### Type-Safe Templating (html / xml)
@@ -397,9 +428,88 @@ Responses are finalized once at the end of the request: automatic gzip (when the
397
428
 
398
429
  **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.
399
430
 
431
+ ### Typed API Refusals (LambderApiError)
432
+
433
+ 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. Throw `LambderApiError` from anywhere in an API call's stack and the pipeline maps it onto the structured envelope instead of the global error handler, so refusals never pollute crash logging and clients get a parseable response:
434
+
435
+ ```typescript
436
+ import { LambderApiError } from "lambder";
437
+
438
+ // In any helper, no resolver needed:
439
+ export const requirePermission = (granted: boolean) => {
440
+ if (!granted) throw new LambderApiError("Permission denied.", {
441
+ notAuthorized: true, // envelope flag -> caller's notAuthorizedHandler
442
+ errorMessage: { type: "warning", content: "Not allowed." }, // any shape your errorMessageHandler expects
443
+ // sessionExpired: true, // optional envelope flag
444
+ // statusCode: 403, // optional; default 200 (avoid 5xx and 422)
445
+ });
446
+ };
447
+ ```
448
+
449
+ `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.
450
+
451
+ 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.
452
+
453
+ ### Declarative API Policies (rate limits, guards, idempotency)
454
+
455
+ 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.
456
+
457
+ ```typescript
458
+ import Lambder, { LambderDdbRateLimiter, LambderDdbIdempotency, LambderApiError } from "lambder";
459
+
460
+ const lambder = new Lambder<SessionData>({ apiPath: "/api" })
461
+ // 1. Rate limiting: your limiter instance + named policies. Each policy
462
+ // declares its windows AND what one counter tracks ("per").
463
+ .enableApiRateLimits({
464
+ limiter: new LambderDdbRateLimiter({ tableName: "app-rate-limiter", region: "us-east-1", failOpen: true }),
465
+ policies: {
466
+ authPerIp: { perMin: 5, perHour: 30, per: "ip" },
467
+ writePerUser: { perMin: 30, per: "session" }, // only referable from addSessionApi (also enforced at compile time)
468
+ codePerEmail: { perMin: 3, per: (ctx) => String(ctx.post?.payload?.email ?? "").toLowerCase(),
469
+ errorMessage: { type: "warning", content: "Too many attempts for this address." } },
470
+ },
471
+ })
472
+ // 2. Idempotency: a store instance + replay defaults. May share the rate
473
+ // limiter's table (records use an IDEM# key prefix).
474
+ .enableApiIdempotency({
475
+ store: new LambderDdbIdempotency({ tableName: "app-rate-limiter", region: "us-east-1" }),
476
+ defaultTtlSeconds: 24 * 3600,
477
+ failOpen: true, // DynamoDB down => execute without dedupe instead of failing
478
+ })
479
+ // 3. Named guards: run before input validation, refuse by throwing.
480
+ // Callable multiple times; domain modules can contribute their own.
481
+ .defineApiGuards({
482
+ captcha: async (ctx) => {
483
+ if (!await verifyCaptcha(ctx.post?.payload?.captchaToken, ctx.ip)) {
484
+ throw new LambderApiError("Captcha failed", { errorMessage: "Verification failed, please retry." });
485
+ }
486
+ },
487
+ });
488
+
489
+ lambder.addApi("public.resetPassword", {
490
+ input: z.object({ email: z.string().email(), captchaToken: z.string() }),
491
+ output: z.object({ ok: z.boolean() }),
492
+ rateLimit: ["authPerIp", "codePerEmail"], // stacked: checked in order, first exceeded refuses (429 envelope)
493
+ guards: "captcha",
494
+ }, handler);
495
+
496
+ lambder.addSessionApi("secure.order.create", {
497
+ input: OrderSchema,
498
+ output: OrderResultSchema,
499
+ rateLimit: "writePerUser",
500
+ idempotency: true, // or { ttlSeconds: 3600 }; type error until enableApiIdempotency()
501
+ }, handler);
502
+ ```
503
+
504
+ Request flow per API: session (session APIs) → rate limits → guards → zod validation → idempotency claim → handler → idempotency store. Refusals ride the envelope via `LambderApiError` (429 rate limited, 409 duplicate in flight), so the caller's `errorMessageHandler` surfaces them with zero client code.
505
+
506
+ **Idempotency semantics**: the client sends an `idempotencyKey` per call (see LambderCaller below); generate it once per logical operation and reuse it on retries. The scope is identity (session key, or IP for public APIs) + API name + key: concurrent duplicates of an in-flight request refuse with 409, repeats of a completed one replay the stored response verbatim until the TTL, and a crashed original releases its claim so a retry actually retries. A response delivered by throwing (`res.die.*`, `throw res.api(...)`) counts as a completion and is stored like a returned one; thrown `LambderApiError` refusals release the claim instead. Responses with status ≥ 500 are never stored. 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.
507
+
508
+ 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).
509
+
400
510
  ### DynamoDB Cache (LambderDdbCache)
401
511
 
402
- Standalone, persistent JSON cache backed by a DynamoDB table (`pk`/`sk` keys + `expiresAt` TTL attribute, same shape as the session table). Brotli-compressed values, in-memory LRU layer, single-flight deduplication, a DynamoDB lease so only one Lambda fills a missing key, and fail-open semantics. Server-only. **Full guide with table setup: [docs/DDB_CACHE.md](./docs/DDB_CACHE.md).**
512
+ 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, in-memory LRU layer, single-flight deduplication, a DynamoDB lease so only one Lambda fills a missing key, and fail-open semantics. Server-only. **Full guide with table setup: [docs/DDB_CACHE.md](./docs/DDB_CACHE.md).**
403
513
 
404
514
  ```typescript
405
515
  import { LambderDdbCache } from "lambder";
@@ -474,6 +584,33 @@ const user = await lambderCaller.api("getCompanyPage", { companyName: "Acme" });
474
584
  // - Expected output type
475
585
  ```
476
586
 
587
+ ### Failure Semantics (apiOutcome, timeouts, per-call overrides)
588
+
589
+ `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:
590
+
591
+ ```typescript
592
+ const outcome = await lambderCaller.apiOutcome("getCompanyPage", { companyName: "Acme" });
593
+ if (outcome.ok) {
594
+ render(outcome.payload);
595
+ } else if (outcome.reason === "network" || outcome.reason === "timeout") {
596
+ showOfflineScreen();
597
+ } else if (outcome.reason === "sessionExpired") {
598
+ redirectToLogin();
599
+ } else {
600
+ // 'server' (5xx / non-envelope body), 'validation' (422), 'versionExpired',
601
+ // 'notAuthorized', 'errorMessage' (structured refusal), 'unknown'
602
+ showError(outcome.errorMessage);
603
+ }
604
+ ```
605
+
606
+ 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.
607
+
608
+ Also available:
609
+
610
+ - **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.
611
+ - **Per-call handler overrides**: every constructor handler (`errorHandler`, `sessionExpiredHandler`, `errorMessageHandler`, ...) can be overridden in the options of a single `api`/`apiRaw`/`apiOutcome` call.
612
+ - **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.
613
+
477
614
  ### Benefits
478
615
 
479
616
  ✅ **No Manual Type Definitions** - Types are inferred from your Zod schemas
package/dist/Lambder.d.ts CHANGED
@@ -5,8 +5,10 @@ import LambderResponseBuilder from "./LambderResponseBuilder.js";
5
5
  import { LambderResponse, type LambderHttpResponse } from "./LambderResponse.js";
6
6
  import { type ConditionFunction, type LambderRouteMatcher, type PathParamsOf } from "./LambderRouting.js";
7
7
  import { type LambderCorsConfig } from "./LambderCors.js";
8
+ import { type LambderSessionDataRefreshConfig } from "./LambderSessionManager.js";
8
9
  import LambderSessionController, { type LambderSessionCookieOptions } from "./LambderSessionController.js";
9
10
  import { type LambderPublicFilesOptions } from "./LambderPublicFiles.js";
11
+ import { type LambderApiRateLimitPolicyConfig, type LambderApiRateLimitsConfig, type LambderApiIdempotencyConfig, type LambderApiGuardFunction, type LambderApiRegistrationOptions, type LambderPublicRateLimitNames } from "./LambderApiPolicies.js";
10
12
  import type { MergeContract } from "./LambderApiContract.js";
11
13
  import { type LambderHttpEvent, type LambderRenderContext, type LambderSessionRenderContext } from "./LambderContext.js";
12
14
  export type { PathParamsOf, RouteCondition, ConditionFunction, LambderRouteMatcher } from "./LambderRouting.js";
@@ -15,7 +17,7 @@ type MaybePromise<T> = T | Promise<T>;
15
17
  type Path = `/${string}`;
16
18
  type ActionFunction = (ctx: LambderRenderContext, resolver: LambderResolver) => MaybePromise<LambderResponse>;
17
19
  type SessionActionFunction<SessionData = any> = (ctx: LambderSessionRenderContext<any, SessionData>, resolver: LambderResolver) => MaybePromise<LambderResponse>;
18
- type HookCreatedFunction = (lambderInstance: Lambder<any, any>) => void | Promise<void>;
20
+ type HookCreatedFunction = (lambderInstance: Lambder<any, any, any, any, any>) => void | Promise<void>;
19
21
  /** Return the (possibly replaced) ctx to continue, a LambderResponse to short-circuit, or an Error to fail. */
20
22
  type HookBeforeRenderFunction = (ctx: LambderRenderContext, resolver: LambderResolver) => MaybePromise<LambderRenderContext | LambderResponse | Error>;
21
23
  type HookAfterRenderFunction = (ctx: LambderRenderContext, resolver: LambderResolver, response: LambderResponse) => MaybePromise<LambderResponse | Error>;
@@ -78,6 +80,9 @@ export type LambderConstructorOptions = {
78
80
  *
79
81
  * @typeParam TSessionData - Type of session data stored in DynamoDB
80
82
  * @typeParam _TContract - @internal Accumulates API contract during chaining (do not pass manually)
83
+ * @typeParam _TRateLimitPolicies - @internal Accumulated by enableApiRateLimits (do not pass manually)
84
+ * @typeParam _TGuardName - @internal Accumulated by defineApiGuards (do not pass manually)
85
+ * @typeParam _TIdempotencyEnabled - @internal Flipped by enableApiIdempotency (do not pass manually)
81
86
  *
82
87
  * @example
83
88
  * ```typescript
@@ -88,7 +93,7 @@ export type LambderConstructorOptions = {
88
93
  * .addApi('createUser', { input: z.object({...}), output: z.object({...}) }, handler);
89
94
  * ```
90
95
  */
91
- export default class Lambder<TSessionData = any, _TContract extends Record<string, any> = {}> {
96
+ export default class Lambder<TSessionData = any, _TContract extends Record<string, any> = {}, _TRateLimitPolicies extends Record<string, LambderApiRateLimitPolicyConfig> = {}, _TGuardName extends string = never, _TIdempotencyEnabled extends boolean = false> {
92
97
  apiPath: string;
93
98
  apiVersion: null | string;
94
99
  publicPath: string;
@@ -104,6 +109,8 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
104
109
  */
105
110
  readonly ApiContract: _TContract;
106
111
  private actionList;
112
+ private apiPolicyEngine;
113
+ private registeredApiNames;
107
114
  private hookList;
108
115
  private createdHooks;
109
116
  private initPromise;
@@ -123,7 +130,7 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
123
130
  private sessionCsrfCookieKey;
124
131
  constructor(options?: LambderConstructorOptions);
125
132
  enableCors(config: boolean | LambderCorsConfig): this;
126
- enableDdbSession({ tableName, tableRegion, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, cookie, partitionKey, sortKey, }: {
133
+ enableDdbSession({ tableName, tableRegion, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, cookie, partitionKey, sortKey, dataRefresh, }: {
127
134
  tableName: string;
128
135
  tableRegion: string;
129
136
  sessionSalt: string;
@@ -134,6 +141,15 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
134
141
  cookie?: LambderSessionCookieOptions;
135
142
  partitionKey?: string;
136
143
  sortKey?: string;
144
+ /**
145
+ * Opt-in freshness for session.data derived from external state
146
+ * (roles, permissions, feature flags...). Every session read
147
+ * renews data past its ttlSeconds via your refresh callback,
148
+ * persisting in place on the same record: same tokens, same
149
+ * cookies. Return null from refresh to end the session. See
150
+ * LambderSessionDataRefreshConfig for the exact semantics.
151
+ */
152
+ dataRefresh?: LambderSessionDataRefreshConfig<TSessionData>;
137
153
  }): this;
138
154
  setSessionCookieKey(sessionTokenCookieKey: string, sessionCsrfCookieKey: string): this;
139
155
  setRouteFallbackHandler(routeFallbackHandler: FallbackHandlerFunction): this;
@@ -163,19 +179,50 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
163
179
  serveIndexHtml(handler?: FallbackHandlerFunction, options?: LambderIndexHtmlOptions): this;
164
180
  /** Apply the serveIndexHtml gates; null means fall through. */
165
181
  private tryServeIndexHtml;
182
+ /**
183
+ * Wire declarative per-API rate limiting: your LambderDdbRateLimiter
184
+ * instance plus named policies, each declaring its windows and what one
185
+ * counter tracks (`per`: "ip", "session", or a custom key function).
186
+ * APIs then reference policies by name via the `rateLimit` option; the
187
+ * returned type narrows so only declared names are accepted, and
188
+ * policies keyed per "session" are only referable from addSessionApi.
189
+ * Callable once; call it before the API registrations that use it.
190
+ */
191
+ enableApiRateLimits<const TPolicies extends Record<string, LambderApiRateLimitPolicyConfig>>(config: LambderApiRateLimitsConfig<TPolicies>): Lambder<TSessionData, _TContract, TPolicies, _TGuardName, _TIdempotencyEnabled>;
192
+ /**
193
+ * Wire declarative idempotency: your LambderDdbIdempotency instance plus
194
+ * replay defaults. APIs opt in via `idempotency: true | { ttlSeconds }`;
195
+ * the option is a type error until this is called. Requests carrying a
196
+ * client `idempotencyKey` (sent by LambderCaller) claim an
197
+ * identity+api+key scope atomically: concurrent duplicates refuse with
198
+ * 409, replays of a completed request return the stored response, and a
199
+ * crashed original releases its claim. Callable once.
200
+ */
201
+ enableApiIdempotency(config: LambderApiIdempotencyConfig): Lambder<TSessionData, _TContract, _TRateLimitPolicies, _TGuardName, true>;
202
+ /**
203
+ * Define named guards that APIs reference (typed) via the `guards`
204
+ * option. Guards run before input validation, in the order the API
205
+ * declares them; a guard refuses by throwing (typically LambderApiError).
206
+ * Callable multiple times so domain modules can contribute their own;
207
+ * names must not collide.
208
+ */
209
+ defineApiGuards<TGuards extends Record<string, LambderApiGuardFunction>>(guards: TGuards): Lambder<TSessionData, _TContract, _TRateLimitPolicies, _TGuardName | Extract<keyof TGuards, string>, _TIdempotencyEnabled>;
210
+ private getOrCreatePolicyEngine;
211
+ /** Registration-time checks shared by addApi/addSessionApi. */
212
+ private assertApiRegistration;
166
213
  addRoute<TPath extends Path>(condition: TPath, actionFn: (ctx: LambderRenderContext<any, PathParamsOf<TPath>>, resolver: LambderResolver) => MaybePromise<LambderResponse>): this;
167
214
  addRoute(condition: RegExp | ConditionFunction | LambderRouteMatcher, actionFn: ActionFunction): this;
168
215
  addSessionRoute<TPath extends Path>(condition: TPath, actionFn: (ctx: LambderSessionRenderContext<any, TSessionData, PathParamsOf<TPath>>, resolver: LambderResolver) => MaybePromise<LambderResponse>): this;
169
216
  addSessionRoute(condition: RegExp | ConditionFunction | LambderRouteMatcher, actionFn: SessionActionFunction<TSessionData>): this;
170
- use<_TNewContract extends Record<string, any>>(plugin: (lambder: Lambder<TSessionData, _TContract>) => Lambder<TSessionData, _TNewContract>): Lambder<TSessionData, _TNewContract extends _TContract ? _TNewContract : (_TContract & _TNewContract)>;
217
+ use<_TNewContract extends Record<string, any>>(plugin: (lambder: Lambder<TSessionData, _TContract, any, any, any>) => Lambder<TSessionData, _TNewContract, any, any, any>): Lambder<TSessionData, _TNewContract extends _TContract ? _TNewContract : (_TContract & _TNewContract), _TRateLimitPolicies, _TGuardName, _TIdempotencyEnabled>;
171
218
  addApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny>(name: TName, schema: {
172
219
  input: TInput;
173
220
  output: TOutput;
174
- }, handler: (ctx: LambderRenderContext<z.infer<TInput>>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>>>;
221
+ } & LambderApiRegistrationOptions<LambderPublicRateLimitNames<_TRateLimitPolicies>, _TGuardName, _TIdempotencyEnabled>, handler: (ctx: LambderRenderContext<z.infer<TInput>>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>>, _TRateLimitPolicies, _TGuardName, _TIdempotencyEnabled>;
175
222
  addSessionApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny>(name: TName, schema: {
176
223
  input: TInput;
177
224
  output: TOutput;
178
- }, handler: (ctx: LambderSessionRenderContext<z.infer<TInput>, TSessionData>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>>>;
225
+ } & LambderApiRegistrationOptions<Extract<keyof _TRateLimitPolicies, string>, _TGuardName, _TIdempotencyEnabled>, handler: (ctx: LambderSessionRenderContext<z.infer<TInput>, TSessionData>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>>, _TRateLimitPolicies, _TGuardName, _TIdempotencyEnabled>;
179
226
  /**
180
227
  * Fetch the session or short-circuit the request: API calls get the
181
228
  * protocol's { sessionExpired: true } response (handled by LambderCaller),
@@ -189,6 +236,8 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
189
236
  getSessionController(ctx: LambderRenderContext | LambderSessionRenderContext<any, TSessionData>): LambderSessionController<TSessionData>;
190
237
  getResponseBuilder(ctx?: LambderRenderContext): LambderResponseBuilder<any>;
191
238
  private getResolver;
239
+ /** Map a thrown LambderApiError onto the structured API envelope. */
240
+ private apiErrorResponse;
192
241
  getHandler(): LambderHandler;
193
242
  /** True when the Lambda event is an API Gateway HTTP event (REST API v1 or HTTP API / Function URL v2). */
194
243
  static isHttpEvent(event: unknown): event is LambderHttpEvent;
package/dist/Lambder.js CHANGED
@@ -6,12 +6,17 @@ import { applyCorsHeaders } from "./LambderCors.js";
6
6
  import LambderSessionManager from "./LambderSessionManager.js";
7
7
  import LambderSessionController from "./LambderSessionController.js";
8
8
  import { LambderPublicFilesHandler } from "./LambderPublicFiles.js";
9
+ import { isLambderApiError } from "./LambderApiError.js";
10
+ import { LambderApiPolicyEngine, } from "./LambderApiPolicies.js";
9
11
  import { createContext, isV2HttpEvent } from "./LambderContext.js";
10
12
  /**
11
13
  * Main Lambder class for building type-safe serverless APIs
12
14
  *
13
15
  * @typeParam TSessionData - Type of session data stored in DynamoDB
14
16
  * @typeParam _TContract - @internal Accumulates API contract during chaining (do not pass manually)
17
+ * @typeParam _TRateLimitPolicies - @internal Accumulated by enableApiRateLimits (do not pass manually)
18
+ * @typeParam _TGuardName - @internal Accumulated by defineApiGuards (do not pass manually)
19
+ * @typeParam _TIdempotencyEnabled - @internal Flipped by enableApiIdempotency (do not pass manually)
15
20
  *
16
21
  * @example
17
22
  * ```typescript
@@ -38,6 +43,8 @@ export default class Lambder {
38
43
  */
39
44
  ApiContract;
40
45
  actionList = [];
46
+ apiPolicyEngine = null;
47
+ registeredApiNames = new Set();
41
48
  hookList = { "beforeRender": [], "afterRender": [], "fallback": [] };
42
49
  createdHooks = [];
43
50
  initPromise = null;
@@ -71,12 +78,13 @@ export default class Lambder {
71
78
  this.corsConfig = config === true ? {} : (config === false ? null : config);
72
79
  return this;
73
80
  }
74
- enableDdbSession({ tableName, tableRegion, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, cookie, partitionKey, sortKey, }) {
81
+ enableDdbSession({ tableName, tableRegion, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, cookie, partitionKey, sortKey, dataRefresh, }) {
75
82
  this.lambderSessionManager = new LambderSessionManager({
76
83
  tableName, tableRegion,
77
84
  partitionKey: partitionKey ?? "pk",
78
85
  sortKey: sortKey ?? "sk",
79
86
  sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds,
87
+ dataRefresh,
80
88
  });
81
89
  this.sessionCookieOptions = cookie ?? {};
82
90
  return this;
@@ -154,6 +162,65 @@ export default class Lambder {
154
162
  }
155
163
  return response;
156
164
  }
165
+ // ---------------------------------------------------------------------
166
+ // Declarative API policies (rate limits, guards, idempotency)
167
+ // ---------------------------------------------------------------------
168
+ /**
169
+ * Wire declarative per-API rate limiting: your LambderDdbRateLimiter
170
+ * instance plus named policies, each declaring its windows and what one
171
+ * counter tracks (`per`: "ip", "session", or a custom key function).
172
+ * APIs then reference policies by name via the `rateLimit` option; the
173
+ * returned type narrows so only declared names are accepted, and
174
+ * policies keyed per "session" are only referable from addSessionApi.
175
+ * Callable once; call it before the API registrations that use it.
176
+ */
177
+ enableApiRateLimits(config) {
178
+ this.getOrCreatePolicyEngine().setRateLimits(config);
179
+ return this;
180
+ }
181
+ /**
182
+ * Wire declarative idempotency: your LambderDdbIdempotency instance plus
183
+ * replay defaults. APIs opt in via `idempotency: true | { ttlSeconds }`;
184
+ * the option is a type error until this is called. Requests carrying a
185
+ * client `idempotencyKey` (sent by LambderCaller) claim an
186
+ * identity+api+key scope atomically: concurrent duplicates refuse with
187
+ * 409, replays of a completed request return the stored response, and a
188
+ * crashed original releases its claim. Callable once.
189
+ */
190
+ enableApiIdempotency(config) {
191
+ this.getOrCreatePolicyEngine().setIdempotency(config);
192
+ return this;
193
+ }
194
+ /**
195
+ * Define named guards that APIs reference (typed) via the `guards`
196
+ * option. Guards run before input validation, in the order the API
197
+ * declares them; a guard refuses by throwing (typically LambderApiError).
198
+ * Callable multiple times so domain modules can contribute their own;
199
+ * names must not collide.
200
+ */
201
+ defineApiGuards(guards) {
202
+ this.getOrCreatePolicyEngine().addGuards(guards);
203
+ return this;
204
+ }
205
+ getOrCreatePolicyEngine() {
206
+ if (!this.apiPolicyEngine)
207
+ this.apiPolicyEngine = new LambderApiPolicyEngine();
208
+ return this.apiPolicyEngine;
209
+ }
210
+ /** Registration-time checks shared by addApi/addSessionApi. */
211
+ assertApiRegistration(name, mode, options) {
212
+ if (this.registeredApiNames.has(name)) {
213
+ throw new Error(`Lambder: duplicate API name "${name}". Dispatch is first-match, so the second registration would be silently dead code.`);
214
+ }
215
+ this.registeredApiNames.add(name);
216
+ const usesPolicies = options.rateLimit !== undefined || options.guards !== undefined || options.idempotency !== undefined;
217
+ if (!usesPolicies)
218
+ return;
219
+ if (!this.apiPolicyEngine) {
220
+ throw new Error(`Lambder: API "${name}" declares rateLimit/guards/idempotency, but none of enableApiRateLimits()/defineApiGuards()/enableApiIdempotency() was called first.`);
221
+ }
222
+ this.apiPolicyEngine.assertRegistration(name, mode, options);
223
+ }
157
224
  addRoute(condition, actionFn) {
158
225
  this.actionList.push({
159
226
  match: compileRouteMatcher(condition),
@@ -172,14 +239,21 @@ export default class Lambder {
172
239
  return this;
173
240
  }
174
241
  // Plugin system
242
+ // The policy generics are `any` in the plugin signature on purpose: a
243
+ // module may annotate its parameter as the bare Lambder<SessionData> or
244
+ // as the app's narrowed alias, and both must chain. Registration-time
245
+ // assertions still verify every referenced policy/guard name at runtime.
175
246
  use(plugin) {
176
247
  return plugin(this);
177
248
  }
178
249
  // Typed API with Zod
179
250
  addApi(name, schema, handler) {
251
+ this.assertApiRegistration(name, "public", schema);
180
252
  this.actionList.push({
181
253
  match: (ctx) => ctx.apiName === name ? {} : false,
182
254
  actionFn: async (ctx, resolver) => {
255
+ if (this.apiPolicyEngine)
256
+ await this.apiPolicyEngine.runPreflight(name, ctx, resolver, schema);
183
257
  const inputResult = schema.input.safeParse(ctx.apiPayload);
184
258
  if (!inputResult.success) {
185
259
  if (this.apiInputValidationErrorHandler) {
@@ -188,17 +262,23 @@ export default class Lambder {
188
262
  return resolver.json({ error: "Input validation failed", zodError: inputResult.error }, { statusCode: 422 });
189
263
  }
190
264
  ctx.apiPayload = inputResult.data;
191
- return await handler(ctx, resolver);
265
+ const run = async () => await handler(ctx, resolver);
266
+ if (this.apiPolicyEngine && schema.idempotency)
267
+ return await this.apiPolicyEngine.withIdempotency(name, ctx, schema.idempotency, run);
268
+ return await run();
192
269
  },
193
270
  });
194
271
  return this;
195
272
  }
196
273
  // Typed Session API with Zod
197
274
  addSessionApi(name, schema, handler) {
275
+ this.assertApiRegistration(name, "session", schema);
198
276
  this.actionList.push({
199
277
  match: (ctx) => ctx.apiName === name ? {} : false,
200
278
  actionFn: async (ctx, resolver) => {
201
279
  await this.requireSession(ctx, resolver);
280
+ if (this.apiPolicyEngine)
281
+ await this.apiPolicyEngine.runPreflight(name, ctx, resolver, schema);
202
282
  const inputResult = schema.input.safeParse(ctx.apiPayload);
203
283
  if (!inputResult.success) {
204
284
  if (this.apiInputValidationErrorHandler) {
@@ -207,7 +287,10 @@ export default class Lambder {
207
287
  return resolver.json({ error: "Input validation failed", zodError: inputResult.error }, { statusCode: 422 });
208
288
  }
209
289
  ctx.apiPayload = inputResult.data;
210
- return await handler(ctx, resolver);
290
+ const run = async () => await handler(ctx, resolver);
291
+ if (this.apiPolicyEngine && schema.idempotency)
292
+ return await this.apiPolicyEngine.withIdempotency(name, ctx, schema.idempotency, run);
293
+ return await run();
211
294
  }
212
295
  });
213
296
  return this;
@@ -267,6 +350,14 @@ export default class Lambder {
267
350
  });
268
351
  }
269
352
  ;
353
+ /** Map a thrown LambderApiError onto the structured API envelope. */
354
+ apiErrorResponse(err, resolver) {
355
+ return resolver.api(null, {
356
+ ...(err.errorMessage !== undefined ? { errorMessage: err.errorMessage } : {}),
357
+ ...(err.notAuthorized ? { notAuthorized: true } : {}),
358
+ ...(err.sessionExpired ? { sessionExpired: true } : {}),
359
+ }, err.statusCode !== undefined ? { statusCode: err.statusCode } : undefined);
360
+ }
270
361
  getHandler() {
271
362
  return ((event, context) => Lambder.isHttpEvent(event)
272
363
  ? this.render(event, context)
@@ -400,6 +491,11 @@ export default class Lambder {
400
491
  if (err instanceof LambderResponse) {
401
492
  response = err;
402
493
  }
494
+ // A thrown LambderApiError on an API call IS a structured refusal
495
+ // (brand-checked, not instanceof, to survive duplicate installs).
496
+ else if (isLambderApiError(err) && ctx._otherInternal.isApiCall) {
497
+ response = this.apiErrorResponse(err, resolver);
498
+ }
403
499
  else {
404
500
  throw err;
405
501
  }
@@ -416,6 +512,9 @@ export default class Lambder {
416
512
  if (err instanceof LambderResponse) {
417
513
  response = err;
418
514
  }
515
+ else if (isLambderApiError(err) && ctx._otherInternal.isApiCall) {
516
+ response = this.apiErrorResponse(err, resolver);
517
+ }
419
518
  else {
420
519
  throw err;
421
520
  }
@@ -449,6 +548,14 @@ export default class Lambder {
449
548
  catch { /* fall through */ }
450
549
  }
451
550
  }
551
+ // Last-resort 500. API calls get the JSON envelope so clients can
552
+ // parse a structured failure; everything else keeps plain text.
553
+ if (ctx?._otherInternal.isApiCall) {
554
+ const apiBody = JSON.stringify({ apiVersion: this.apiVersion, payload: null, errorMessage: "Internal server error." });
555
+ return eventFormat === "v2"
556
+ ? { statusCode: 500, headers: { "Content-Type": "application/json; charset=utf-8" }, body: apiBody, isBase64Encoded: false }
557
+ : { statusCode: 500, multiValueHeaders: { "Content-Type": ["application/json; charset=utf-8"] }, body: apiBody, isBase64Encoded: false };
558
+ }
452
559
  return eventFormat === "v2"
453
560
  ? { statusCode: 500, headers: {}, body: "Internal Server Error.", isBase64Encoded: false }
454
561
  : { statusCode: 500, multiValueHeaders: {}, body: "Internal Server Error.", isBase64Encoded: false };
@@ -0,0 +1,54 @@
1
+ import type { HttpStatusCode } from "./LambderResponse.js";
2
+ export type LambderApiErrorOptions = {
3
+ /**
4
+ * Structured, user-facing failure detail placed on the API envelope's
5
+ * `errorMessage` field. Any shape the app's errorMessageHandler expects
6
+ * (e.g. `{ type: "warning", content: "..." }`). Defaults to the error
7
+ * message string, so a bare `throw new LambderApiError("...")` is still
8
+ * visible to the client.
9
+ */
10
+ errorMessage?: any;
11
+ /** Sets the envelope's `notAuthorized` flag (routed to the caller's notAuthorizedHandler). */
12
+ notAuthorized?: boolean;
13
+ /** Sets the envelope's `sessionExpired` flag (the caller clears session cookies and calls sessionExpiredHandler). */
14
+ sessionExpired?: boolean;
15
+ /**
16
+ * HTTP status of the refusal response. Default 200: the envelope is the
17
+ * semantic channel. Avoid 5xx (LambderCaller treats those as crashes) and
18
+ * 422 (reserved for input validation).
19
+ */
20
+ statusCode?: HttpStatusCode;
21
+ /** Underlying cause, preserved on the standard Error `cause` property. */
22
+ cause?: unknown;
23
+ };
24
+ /**
25
+ * A typed refusal: "this request is denied/invalid" as opposed to "the server
26
+ * crashed". Throw it from anywhere in an API call's call stack — handlers,
27
+ * hooks, or nested helpers that have no access to the per-request resolver —
28
+ * and the render pipeline maps it onto the structured API envelope
29
+ * (`res.api(null, { errorMessage, notAuthorized, sessionExpired })`) instead
30
+ * of routing it through setGlobalErrorHandler. Refusals therefore never reach
31
+ * crash logging, and clients receive a parseable response they can surface.
32
+ *
33
+ * Thrown outside an API call (e.g. in a route handler) it behaves like any
34
+ * other error: global error handler, then the default 500.
35
+ *
36
+ * Isomorphic and dependency-free, so shared code (validators, permission
37
+ * checks) may import and throw it from packages used by both server and
38
+ * browser builds; in the browser it is just an Error.
39
+ */
40
+ export declare class LambderApiError extends Error {
41
+ /**
42
+ * Brand for detection across duplicate lambder installs: when two copies
43
+ * of the package coexist in one bundle, `instanceof LambderApiError` fails
44
+ * across them while this marker does not. The pipeline checks the brand.
45
+ */
46
+ readonly isLambderApiError = true;
47
+ readonly errorMessage?: any;
48
+ readonly notAuthorized?: boolean;
49
+ readonly sessionExpired?: boolean;
50
+ readonly statusCode?: HttpStatusCode;
51
+ constructor(message: string, options?: LambderApiErrorOptions);
52
+ }
53
+ /** Brand-based type guard (see LambderApiError.isLambderApiError). */
54
+ export declare const isLambderApiError: (err: unknown) => err is LambderApiError;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * A typed refusal: "this request is denied/invalid" as opposed to "the server
3
+ * crashed". Throw it from anywhere in an API call's call stack — handlers,
4
+ * hooks, or nested helpers that have no access to the per-request resolver —
5
+ * and the render pipeline maps it onto the structured API envelope
6
+ * (`res.api(null, { errorMessage, notAuthorized, sessionExpired })`) instead
7
+ * of routing it through setGlobalErrorHandler. Refusals therefore never reach
8
+ * crash logging, and clients receive a parseable response they can surface.
9
+ *
10
+ * Thrown outside an API call (e.g. in a route handler) it behaves like any
11
+ * other error: global error handler, then the default 500.
12
+ *
13
+ * Isomorphic and dependency-free, so shared code (validators, permission
14
+ * checks) may import and throw it from packages used by both server and
15
+ * browser builds; in the browser it is just an Error.
16
+ */
17
+ export class LambderApiError extends Error {
18
+ /**
19
+ * Brand for detection across duplicate lambder installs: when two copies
20
+ * of the package coexist in one bundle, `instanceof LambderApiError` fails
21
+ * across them while this marker does not. The pipeline checks the brand.
22
+ */
23
+ isLambderApiError = true;
24
+ errorMessage;
25
+ notAuthorized;
26
+ sessionExpired;
27
+ statusCode;
28
+ constructor(message, options = {}) {
29
+ super(message, options.cause !== undefined ? { cause: options.cause } : undefined);
30
+ this.name = "LambderApiError";
31
+ this.errorMessage = options.errorMessage ?? message;
32
+ this.notAuthorized = options.notAuthorized;
33
+ this.sessionExpired = options.sessionExpired;
34
+ this.statusCode = options.statusCode;
35
+ }
36
+ }
37
+ /** Brand-based type guard (see LambderApiError.isLambderApiError). */
38
+ export const isLambderApiError = (err) => err instanceof Error && err.isLambderApiError === true;