lambder 3.4.2 → 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 +108 -2
- package/dist/Lambder.d.ts +44 -5
- package/dist/Lambder.js +108 -2
- package/dist/LambderApiError.d.ts +54 -0
- package/dist/LambderApiError.js +38 -0
- package/dist/LambderApiPolicies.d.ts +95 -0
- package/dist/LambderApiPolicies.js +196 -0
- package/dist/LambderCaller.d.ts +77 -24
- package/dist/LambderCaller.js +212 -74
- package/dist/LambderDdbCache.d.ts +8 -0
- package/dist/LambderDdbCache.js +8 -1
- package/dist/LambderDdbIdempotency.d.ts +72 -0
- package/dist/LambderDdbIdempotency.js +132 -0
- package/dist/LambderDdbRateLimiter.d.ts +4 -1
- package/dist/LambderDdbRateLimiter.js +3 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/package.json +1 -1
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)
|
|
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
|
|
|
@@ -428,9 +428,88 @@ Responses are finalized once at the end of the request: automatic gzip (when the
|
|
|
428
428
|
|
|
429
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.
|
|
430
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
|
+
|
|
431
510
|
### DynamoDB Cache (LambderDdbCache)
|
|
432
511
|
|
|
433
|
-
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).**
|
|
434
513
|
|
|
435
514
|
```typescript
|
|
436
515
|
import { LambderDdbCache } from "lambder";
|
|
@@ -505,6 +584,33 @@ const user = await lambderCaller.api("getCompanyPage", { companyName: "Acme" });
|
|
|
505
584
|
// - Expected output type
|
|
506
585
|
```
|
|
507
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
|
+
|
|
508
614
|
### Benefits
|
|
509
615
|
|
|
510
616
|
✅ **No Manual Type Definitions** - Types are inferred from your Zod schemas
|
package/dist/Lambder.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { type LambderCorsConfig } from "./LambderCors.js";
|
|
|
8
8
|
import { type LambderSessionDataRefreshConfig } from "./LambderSessionManager.js";
|
|
9
9
|
import LambderSessionController, { type LambderSessionCookieOptions } from "./LambderSessionController.js";
|
|
10
10
|
import { type LambderPublicFilesOptions } from "./LambderPublicFiles.js";
|
|
11
|
+
import { type LambderApiRateLimitPolicyConfig, type LambderApiRateLimitsConfig, type LambderApiIdempotencyConfig, type LambderApiGuardFunction, type LambderApiRegistrationOptions, type LambderPublicRateLimitNames } from "./LambderApiPolicies.js";
|
|
11
12
|
import type { MergeContract } from "./LambderApiContract.js";
|
|
12
13
|
import { type LambderHttpEvent, type LambderRenderContext, type LambderSessionRenderContext } from "./LambderContext.js";
|
|
13
14
|
export type { PathParamsOf, RouteCondition, ConditionFunction, LambderRouteMatcher } from "./LambderRouting.js";
|
|
@@ -16,7 +17,7 @@ type MaybePromise<T> = T | Promise<T>;
|
|
|
16
17
|
type Path = `/${string}`;
|
|
17
18
|
type ActionFunction = (ctx: LambderRenderContext, resolver: LambderResolver) => MaybePromise<LambderResponse>;
|
|
18
19
|
type SessionActionFunction<SessionData = any> = (ctx: LambderSessionRenderContext<any, SessionData>, resolver: LambderResolver) => MaybePromise<LambderResponse>;
|
|
19
|
-
type HookCreatedFunction = (lambderInstance: Lambder<any, any>) => void | Promise<void>;
|
|
20
|
+
type HookCreatedFunction = (lambderInstance: Lambder<any, any, any, any, any>) => void | Promise<void>;
|
|
20
21
|
/** Return the (possibly replaced) ctx to continue, a LambderResponse to short-circuit, or an Error to fail. */
|
|
21
22
|
type HookBeforeRenderFunction = (ctx: LambderRenderContext, resolver: LambderResolver) => MaybePromise<LambderRenderContext | LambderResponse | Error>;
|
|
22
23
|
type HookAfterRenderFunction = (ctx: LambderRenderContext, resolver: LambderResolver, response: LambderResponse) => MaybePromise<LambderResponse | Error>;
|
|
@@ -79,6 +80,9 @@ export type LambderConstructorOptions = {
|
|
|
79
80
|
*
|
|
80
81
|
* @typeParam TSessionData - Type of session data stored in DynamoDB
|
|
81
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)
|
|
82
86
|
*
|
|
83
87
|
* @example
|
|
84
88
|
* ```typescript
|
|
@@ -89,7 +93,7 @@ export type LambderConstructorOptions = {
|
|
|
89
93
|
* .addApi('createUser', { input: z.object({...}), output: z.object({...}) }, handler);
|
|
90
94
|
* ```
|
|
91
95
|
*/
|
|
92
|
-
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> {
|
|
93
97
|
apiPath: string;
|
|
94
98
|
apiVersion: null | string;
|
|
95
99
|
publicPath: string;
|
|
@@ -105,6 +109,8 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
105
109
|
*/
|
|
106
110
|
readonly ApiContract: _TContract;
|
|
107
111
|
private actionList;
|
|
112
|
+
private apiPolicyEngine;
|
|
113
|
+
private registeredApiNames;
|
|
108
114
|
private hookList;
|
|
109
115
|
private createdHooks;
|
|
110
116
|
private initPromise;
|
|
@@ -173,19 +179,50 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
173
179
|
serveIndexHtml(handler?: FallbackHandlerFunction, options?: LambderIndexHtmlOptions): this;
|
|
174
180
|
/** Apply the serveIndexHtml gates; null means fall through. */
|
|
175
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;
|
|
176
213
|
addRoute<TPath extends Path>(condition: TPath, actionFn: (ctx: LambderRenderContext<any, PathParamsOf<TPath>>, resolver: LambderResolver) => MaybePromise<LambderResponse>): this;
|
|
177
214
|
addRoute(condition: RegExp | ConditionFunction | LambderRouteMatcher, actionFn: ActionFunction): this;
|
|
178
215
|
addSessionRoute<TPath extends Path>(condition: TPath, actionFn: (ctx: LambderSessionRenderContext<any, TSessionData, PathParamsOf<TPath>>, resolver: LambderResolver) => MaybePromise<LambderResponse>): this;
|
|
179
216
|
addSessionRoute(condition: RegExp | ConditionFunction | LambderRouteMatcher, actionFn: SessionActionFunction<TSessionData>): this;
|
|
180
|
-
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>;
|
|
181
218
|
addApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny>(name: TName, schema: {
|
|
182
219
|
input: TInput;
|
|
183
220
|
output: TOutput;
|
|
184
|
-
}, 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>;
|
|
185
222
|
addSessionApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny>(name: TName, schema: {
|
|
186
223
|
input: TInput;
|
|
187
224
|
output: TOutput;
|
|
188
|
-
}, 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>;
|
|
189
226
|
/**
|
|
190
227
|
* Fetch the session or short-circuit the request: API calls get the
|
|
191
228
|
* protocol's { sessionExpired: true } response (handled by LambderCaller),
|
|
@@ -199,6 +236,8 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
199
236
|
getSessionController(ctx: LambderRenderContext | LambderSessionRenderContext<any, TSessionData>): LambderSessionController<TSessionData>;
|
|
200
237
|
getResponseBuilder(ctx?: LambderRenderContext): LambderResponseBuilder<any>;
|
|
201
238
|
private getResolver;
|
|
239
|
+
/** Map a thrown LambderApiError onto the structured API envelope. */
|
|
240
|
+
private apiErrorResponse;
|
|
202
241
|
getHandler(): LambderHandler;
|
|
203
242
|
/** True when the Lambda event is an API Gateway HTTP event (REST API v1 or HTTP API / Function URL v2). */
|
|
204
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;
|
|
@@ -155,6 +162,65 @@ export default class Lambder {
|
|
|
155
162
|
}
|
|
156
163
|
return response;
|
|
157
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
|
+
}
|
|
158
224
|
addRoute(condition, actionFn) {
|
|
159
225
|
this.actionList.push({
|
|
160
226
|
match: compileRouteMatcher(condition),
|
|
@@ -173,14 +239,21 @@ export default class Lambder {
|
|
|
173
239
|
return this;
|
|
174
240
|
}
|
|
175
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.
|
|
176
246
|
use(plugin) {
|
|
177
247
|
return plugin(this);
|
|
178
248
|
}
|
|
179
249
|
// Typed API with Zod
|
|
180
250
|
addApi(name, schema, handler) {
|
|
251
|
+
this.assertApiRegistration(name, "public", schema);
|
|
181
252
|
this.actionList.push({
|
|
182
253
|
match: (ctx) => ctx.apiName === name ? {} : false,
|
|
183
254
|
actionFn: async (ctx, resolver) => {
|
|
255
|
+
if (this.apiPolicyEngine)
|
|
256
|
+
await this.apiPolicyEngine.runPreflight(name, ctx, resolver, schema);
|
|
184
257
|
const inputResult = schema.input.safeParse(ctx.apiPayload);
|
|
185
258
|
if (!inputResult.success) {
|
|
186
259
|
if (this.apiInputValidationErrorHandler) {
|
|
@@ -189,17 +262,23 @@ export default class Lambder {
|
|
|
189
262
|
return resolver.json({ error: "Input validation failed", zodError: inputResult.error }, { statusCode: 422 });
|
|
190
263
|
}
|
|
191
264
|
ctx.apiPayload = inputResult.data;
|
|
192
|
-
|
|
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();
|
|
193
269
|
},
|
|
194
270
|
});
|
|
195
271
|
return this;
|
|
196
272
|
}
|
|
197
273
|
// Typed Session API with Zod
|
|
198
274
|
addSessionApi(name, schema, handler) {
|
|
275
|
+
this.assertApiRegistration(name, "session", schema);
|
|
199
276
|
this.actionList.push({
|
|
200
277
|
match: (ctx) => ctx.apiName === name ? {} : false,
|
|
201
278
|
actionFn: async (ctx, resolver) => {
|
|
202
279
|
await this.requireSession(ctx, resolver);
|
|
280
|
+
if (this.apiPolicyEngine)
|
|
281
|
+
await this.apiPolicyEngine.runPreflight(name, ctx, resolver, schema);
|
|
203
282
|
const inputResult = schema.input.safeParse(ctx.apiPayload);
|
|
204
283
|
if (!inputResult.success) {
|
|
205
284
|
if (this.apiInputValidationErrorHandler) {
|
|
@@ -208,7 +287,10 @@ export default class Lambder {
|
|
|
208
287
|
return resolver.json({ error: "Input validation failed", zodError: inputResult.error }, { statusCode: 422 });
|
|
209
288
|
}
|
|
210
289
|
ctx.apiPayload = inputResult.data;
|
|
211
|
-
|
|
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();
|
|
212
294
|
}
|
|
213
295
|
});
|
|
214
296
|
return this;
|
|
@@ -268,6 +350,14 @@ export default class Lambder {
|
|
|
268
350
|
});
|
|
269
351
|
}
|
|
270
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
|
+
}
|
|
271
361
|
getHandler() {
|
|
272
362
|
return ((event, context) => Lambder.isHttpEvent(event)
|
|
273
363
|
? this.render(event, context)
|
|
@@ -401,6 +491,11 @@ export default class Lambder {
|
|
|
401
491
|
if (err instanceof LambderResponse) {
|
|
402
492
|
response = err;
|
|
403
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
|
+
}
|
|
404
499
|
else {
|
|
405
500
|
throw err;
|
|
406
501
|
}
|
|
@@ -417,6 +512,9 @@ export default class Lambder {
|
|
|
417
512
|
if (err instanceof LambderResponse) {
|
|
418
513
|
response = err;
|
|
419
514
|
}
|
|
515
|
+
else if (isLambderApiError(err) && ctx._otherInternal.isApiCall) {
|
|
516
|
+
response = this.apiErrorResponse(err, resolver);
|
|
517
|
+
}
|
|
420
518
|
else {
|
|
421
519
|
throw err;
|
|
422
520
|
}
|
|
@@ -450,6 +548,14 @@ export default class Lambder {
|
|
|
450
548
|
catch { /* fall through */ }
|
|
451
549
|
}
|
|
452
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
|
+
}
|
|
453
559
|
return eventFormat === "v2"
|
|
454
560
|
? { statusCode: 500, headers: {}, body: "Internal Server Error.", isBase64Encoded: false }
|
|
455
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;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { LambderRenderContext } from "./LambderContext.js";
|
|
2
|
+
import type LambderResolver from "./LambderResolver.js";
|
|
3
|
+
import type { LambderRateLimitPolicy, LambderDdbRateLimiter } from "./LambderDdbRateLimiter.js";
|
|
4
|
+
import type { LambderDdbIdempotency } from "./LambderDdbIdempotency.js";
|
|
5
|
+
import { LambderResponse } from "./LambderResponse.js";
|
|
6
|
+
/**
|
|
7
|
+
* What one rate-limit counter tracks: the client IP, the session identity, or
|
|
8
|
+
* a custom key derived from the request (e.g. a normalized email). Custom
|
|
9
|
+
* functions run before input validation, so they read the raw payload.
|
|
10
|
+
*/
|
|
11
|
+
export type LambderRateLimitPer = "ip" | "session" | ((ctx: LambderRenderContext) => string | Promise<string>);
|
|
12
|
+
/** A named rate-limit policy: fixed windows plus the key one counter tracks. */
|
|
13
|
+
export type LambderApiRateLimitPolicyConfig = LambderRateLimitPolicy & {
|
|
14
|
+
per: LambderRateLimitPer;
|
|
15
|
+
/** Envelope errorMessage for refused requests. Default: a generic too-many-requests message. */
|
|
16
|
+
errorMessage?: any;
|
|
17
|
+
};
|
|
18
|
+
export type LambderApiRateLimitsConfig<TPolicies extends Record<string, LambderApiRateLimitPolicyConfig>> = {
|
|
19
|
+
/** Your limiter instance; its table, keyPrefix and failOpen apply as configured on it. */
|
|
20
|
+
limiter: LambderDdbRateLimiter;
|
|
21
|
+
/** Named policies referenced (typed) from addApi/addSessionApi. */
|
|
22
|
+
policies: TPolicies;
|
|
23
|
+
};
|
|
24
|
+
export type LambderApiIdempotencyConfig = {
|
|
25
|
+
/** Your idempotency store instance; may share the rate limiter's table (distinct key prefix). */
|
|
26
|
+
store: LambderDdbIdempotency;
|
|
27
|
+
/** Seconds a stored response replays for. Default: 86400 (24h). Per-API override: idempotency: { ttlSeconds }. */
|
|
28
|
+
defaultTtlSeconds?: number;
|
|
29
|
+
/** Skip idempotency (execute normally) when DynamoDB errors, instead of failing the request. Default: true. */
|
|
30
|
+
failOpen?: boolean;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* A named guard, run before input validation. Refuse by throwing (typically a
|
|
34
|
+
* LambderApiError, or res.die.*); return normally to let the request through.
|
|
35
|
+
* ctx.apiPayload is unvalidated at this point.
|
|
36
|
+
*/
|
|
37
|
+
export type LambderApiGuardFunction = (ctx: LambderRenderContext, res: LambderResolver) => void | Promise<void>;
|
|
38
|
+
/** Names of policies usable on public APIs: everything not keyed per "session". */
|
|
39
|
+
export type LambderPublicRateLimitNames<TPolicies> = {
|
|
40
|
+
[K in keyof TPolicies]: TPolicies[K] extends {
|
|
41
|
+
per: "session";
|
|
42
|
+
} ? never : K;
|
|
43
|
+
}[keyof TPolicies] & string;
|
|
44
|
+
/** Declarative per-API options carried in the addApi/addSessionApi schema object. */
|
|
45
|
+
export type LambderApiRegistrationOptions<TRateLimitName extends string, TGuardName extends string, TIdempotencyEnabled extends boolean> = {
|
|
46
|
+
/** Named rate limits, checked in declared order before guards and validation; the first exceeded one refuses (429 envelope). */
|
|
47
|
+
rateLimit?: TRateLimitName | readonly TRateLimitName[];
|
|
48
|
+
/** Named guards, run in declared order before input validation; refuse by throwing. */
|
|
49
|
+
guards?: TGuardName | readonly TGuardName[];
|
|
50
|
+
/** Replay-protect this API per client idempotencyKey. Requires enableApiIdempotency() first. */
|
|
51
|
+
idempotency?: TIdempotencyEnabled extends true ? (boolean | {
|
|
52
|
+
ttlSeconds?: number;
|
|
53
|
+
}) : never;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Runtime side of the declarative API options: holds what the enable/define
|
|
57
|
+
* calls declared, asserts registrations against it at startup, and executes
|
|
58
|
+
* rate limits, guards, and idempotency around handlers at request time.
|
|
59
|
+
* Internal to Lambder; apps interact through enableApiRateLimits(),
|
|
60
|
+
* enableApiIdempotency(), defineApiGuards() and the per-API options.
|
|
61
|
+
*/
|
|
62
|
+
export declare class LambderApiPolicyEngine {
|
|
63
|
+
private limiter;
|
|
64
|
+
private rateLimitPolicies;
|
|
65
|
+
private guards;
|
|
66
|
+
private idempotencyStore;
|
|
67
|
+
private idempotencyDefaultTtlSeconds;
|
|
68
|
+
private idempotencyFailOpen;
|
|
69
|
+
setRateLimits(config: LambderApiRateLimitsConfig<Record<string, LambderApiRateLimitPolicyConfig>>): void;
|
|
70
|
+
addGuards(guards: Record<string, LambderApiGuardFunction>): void;
|
|
71
|
+
setIdempotency(config: LambderApiIdempotencyConfig): void;
|
|
72
|
+
/** Startup validation of one API registration's declarative options. */
|
|
73
|
+
assertRegistration(apiName: string, mode: "public" | "session", options: {
|
|
74
|
+
rateLimit?: string | readonly string[];
|
|
75
|
+
guards?: string | readonly string[];
|
|
76
|
+
idempotency?: unknown;
|
|
77
|
+
}): void;
|
|
78
|
+
/** Rate limits then guards, in declared order. Refusals throw (LambderApiError or a guard's own throw). */
|
|
79
|
+
runPreflight(apiName: string, ctx: LambderRenderContext, resolver: LambderResolver, options: {
|
|
80
|
+
rateLimit?: string | readonly string[];
|
|
81
|
+
guards?: string | readonly string[];
|
|
82
|
+
}): Promise<void>;
|
|
83
|
+
private resolveRateLimitKey;
|
|
84
|
+
/**
|
|
85
|
+
* Idempotency wrapper around validation-passed handler execution. Without
|
|
86
|
+
* a client idempotencyKey the handler just runs; with one, the scope
|
|
87
|
+
* (identity + api + key) is claimed atomically: duplicates of an
|
|
88
|
+
* in-flight original refuse with 409, replays of a completed one return
|
|
89
|
+
* the stored response verbatim, and a crashed original releases its claim
|
|
90
|
+
* so a retry actually retries.
|
|
91
|
+
*/
|
|
92
|
+
withIdempotency(apiName: string, ctx: LambderRenderContext, config: boolean | {
|
|
93
|
+
ttlSeconds?: number;
|
|
94
|
+
}, exec: () => Promise<LambderResponse>): Promise<LambderResponse>;
|
|
95
|
+
}
|