lambder 4.0.1 → 4.2.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 +99 -55
- package/dist/client/LambderCaller.d.ts +2 -0
- package/dist/client/LambderCaller.js +8 -4
- package/dist/core/Lambder.d.ts +100 -86
- package/dist/core/Lambder.js +100 -87
- package/dist/core/LambderCors.d.ts +6 -0
- package/dist/core/LambderCors.js +5 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +1 -0
- package/dist/policies/LambderApiGuards.d.ts +17 -6
- package/dist/policies/LambderApiGuards.js +13 -9
- package/dist/policies/LambderApiIdempotency.d.ts +1 -1
- package/dist/policies/LambderApiIdempotency.js +8 -4
- package/dist/policies/LambderApiPolicies.d.ts +6 -4
- package/dist/policies/LambderApiPolicies.js +9 -4
- package/dist/policies/LambderApiRateLimits.d.ts +63 -20
- package/dist/policies/LambderApiRateLimits.js +54 -15
- package/dist/session/LambderSessionController.d.ts +1 -1
- package/dist/session/LambderSessionController.js +1 -1
- package/dist/session/LambderSessionManager.js +1 -1
- package/dist/shared/LambderApiError.d.ts +5 -0
- package/dist/shared/LambderApiError.js +3 -0
- package/dist/stores/LambderDdbRateLimiter.d.ts +45 -14
- package/dist/stores/LambderDdbRateLimiter.js +19 -10
- package/package.json +1 -1
package/Readme.md
CHANGED
|
@@ -2,15 +2,23 @@
|
|
|
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 4.2:**
|
|
6
|
+
|
|
7
|
+
- **Rate-limit budgets are explicit**: every policy declares `budget: "perApi"` (each referencing API gets its own counter, so the numbers are a per-API ceiling) or `budget: "perPolicy"` (one counter shared by every API referencing the policy). There is no default, so a declaration always says what its numbers span; the policy is the group, and two separate shared budgets are two policies.
|
|
8
|
+
- **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.
|
|
9
|
+
- **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`.
|
|
10
|
+
- **One refusal shape**: every refusal the framework itself authors (rate limit 429, idempotency 409 and 400, unknown API) is a `LambderRefusalMessage` (`{ type, content }`), and a policy's `errorMessage` is typed as one, so an `errorMessageHandler` reading `.content` works everywhere.
|
|
11
|
+
- **One validation path**: preflight slices (guard `apiInput`/`guardInput`, rate-limit `apiInput` keys) answer through `setApiInputValidationErrorHandler` exactly like the API's own schema.
|
|
12
|
+
|
|
5
13
|
**New in v4:**
|
|
6
14
|
|
|
7
15
|
- **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.
|
|
8
|
-
- **Hardened policy layer**: rate-limit policies can share one counter across APIs (`
|
|
16
|
+
- **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.
|
|
9
17
|
- **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.
|
|
10
18
|
- **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.
|
|
11
|
-
- **
|
|
19
|
+
- **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.
|
|
12
20
|
|
|
13
|
-
**Breaking in v4** (from 3.x): 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.
|
|
21
|
+
**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.
|
|
14
22
|
|
|
15
23
|
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.
|
|
16
24
|
|
|
@@ -63,25 +71,32 @@ Source layout mirrors this: `src/core/` (request pipeline), `src/policies/` (dec
|
|
|
63
71
|
|
|
64
72
|
### Basic Setup
|
|
65
73
|
|
|
74
|
+
The whole configuration is given at creation, in one declaration; only
|
|
75
|
+
registration (routes, apis, hooks, `use()`) chains afterwards. `initLambder`
|
|
76
|
+
is curried so the session data type is fixed first and everything else
|
|
77
|
+
(policy names, guard metadata) is INFERRED from the options; TypeScript type
|
|
78
|
+
arguments are all-or-nothing per call, so a plain `new Lambder<SessionData>({...})`
|
|
79
|
+
would silently widen the inferred policy types, which is why the curried
|
|
80
|
+
creator is the canonical entry.
|
|
81
|
+
|
|
66
82
|
```typescript
|
|
67
|
-
import
|
|
83
|
+
import { initLambder } from 'lambder';
|
|
68
84
|
import { z } from 'zod';
|
|
69
85
|
import * as path from 'path';
|
|
70
86
|
|
|
71
|
-
|
|
87
|
+
interface SessionData { userId: string; }
|
|
88
|
+
|
|
89
|
+
const lambder = initLambder<SessionData>().create({
|
|
72
90
|
apiPath: "/api",
|
|
73
91
|
publicPath: path.resolve(`./public`),
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
// Enable session and CORS
|
|
77
|
-
lambder
|
|
78
|
-
.enableDdbSession({
|
|
92
|
+
session: {
|
|
79
93
|
tableName: "website-session",
|
|
80
94
|
tableRegion: "us-east-1",
|
|
81
|
-
sessionSalt: "CHANGE-THIS-TO-A-SECURE-RANDOM-STRING"
|
|
82
|
-
}
|
|
95
|
+
sessionSalt: "CHANGE-THIS-TO-A-SECURE-RANDOM-STRING",
|
|
96
|
+
},
|
|
83
97
|
// true allows any origin; or configure: { origins: ["https://app.example.com"], credentials: true }
|
|
84
|
-
|
|
98
|
+
cors: true,
|
|
99
|
+
});
|
|
85
100
|
|
|
86
101
|
// Define type-safe APIs with Zod schemas
|
|
87
102
|
lambder
|
|
@@ -277,18 +292,21 @@ lambder
|
|
|
277
292
|
|
|
278
293
|
### Session Management
|
|
279
294
|
|
|
280
|
-
Enable DynamoDB-based sessions with `
|
|
295
|
+
Enable DynamoDB-based sessions with the `session` option at creation:
|
|
281
296
|
|
|
282
297
|
```typescript
|
|
283
|
-
lambder
|
|
284
|
-
|
|
298
|
+
const lambder = initLambder<SessionData>().create({
|
|
299
|
+
apiPath: "/api",
|
|
300
|
+
session: {
|
|
285
301
|
tableName: "website-session",
|
|
286
302
|
tableRegion: "us-east-1",
|
|
287
303
|
sessionSalt: "CHANGE-THIS-TO-A-SECURE-RANDOM-STRING",
|
|
288
|
-
enableSlidingExpiration: true // Optional: extend session on each access
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
304
|
+
enableSlidingExpiration: true, // Optional: extend session on each access
|
|
305
|
+
// Optionally customize cookie names (defaults: LMDRSESSIONTKID, LMDRSESSIONCSTK)
|
|
306
|
+
tokenCookieKey: "MY_SESSION_TOKEN",
|
|
307
|
+
csrfCookieKey: "MY_CSRF_TOKEN",
|
|
308
|
+
},
|
|
309
|
+
});
|
|
292
310
|
```
|
|
293
311
|
|
|
294
312
|
#### DynamoDB Session Table Structure
|
|
@@ -308,16 +326,18 @@ The session cookie is `pkHash:secret`: `pkHash = sha256(sessionKey + sessionSalt
|
|
|
308
326
|
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.
|
|
309
327
|
|
|
310
328
|
```typescript
|
|
311
|
-
lambder.
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
329
|
+
const lambder = initLambder<SessionData>().create({
|
|
330
|
+
session: {
|
|
331
|
+
tableName: "website-session",
|
|
332
|
+
tableRegion: "us-east-1",
|
|
333
|
+
sessionSalt: "CHANGE-THIS-TO-A-SECURE-RANDOM-STRING",
|
|
334
|
+
dataRefresh: {
|
|
335
|
+
ttlSeconds: 600, // data is renewed at most every 10 minutes
|
|
336
|
+
refresh: async (session) => {
|
|
337
|
+
const user = await loadUser(session.data.userId);
|
|
338
|
+
if (!user || user.disabled) return null; // null ends the session
|
|
339
|
+
return buildSessionData(user);
|
|
340
|
+
},
|
|
321
341
|
},
|
|
322
342
|
},
|
|
323
343
|
});
|
|
@@ -495,22 +515,27 @@ Related: when an API call crashes with no `setGlobalErrorHandler` (or the handle
|
|
|
495
515
|
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.
|
|
496
516
|
|
|
497
517
|
```typescript
|
|
498
|
-
import
|
|
518
|
+
import { initLambder, LambderDdbRateLimiter, LambderDdbIdempotency, lambderGuard, lambderRateLimitKey, refuse } from "lambder";
|
|
499
519
|
|
|
500
|
-
const lambder =
|
|
520
|
+
const lambder = initLambder<SessionData>().create({
|
|
521
|
+
apiPath: "/api",
|
|
501
522
|
// 1. Rate limiting: your limiter instance + named policies. Each policy
|
|
502
|
-
// declares its windows
|
|
503
|
-
|
|
523
|
+
// declares its windows, what one counter tracks ("per"), and what one
|
|
524
|
+
// budget spans ("budget", required so the numbers are never ambiguous):
|
|
525
|
+
// "perApi" gives every referencing API its own counter (three APIs on a
|
|
526
|
+
// 60/min policy allow one IP 180/min in total), "perPolicy" makes every
|
|
527
|
+
// referencing API share ONE counter. The policy IS the group: separate
|
|
528
|
+
// shared budgets for, say, user APIs and report APIs are two policies.
|
|
529
|
+
rateLimits: {
|
|
504
530
|
limiter: new LambderDdbRateLimiter({ tableName: "app-rate-limiter", region: "us-east-1", failOpen: true }),
|
|
505
531
|
policies: {
|
|
506
|
-
authPerIp: { perMin: 5, perHour: 30, per: "ip" },
|
|
507
|
-
writePerUser: { perMin: 30, per: "session" }, // only referable from addSessionApi (also enforced at compile time)
|
|
532
|
+
authPerIp: { perMin: 5, perHour: 30, per: "ip", budget: "perApi" },
|
|
533
|
+
writePerUser: { perMin: 30, per: "session", budget: "perApi" }, // only referable from addSessionApi (also enforced at compile time)
|
|
508
534
|
codePerEmail: {
|
|
509
535
|
perMin: 3,
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
|
|
513
|
-
scope: "policy",
|
|
536
|
+
// ONE combined budget across every API that references this
|
|
537
|
+
// policy: send + register + reset share the 3/min.
|
|
538
|
+
budget: "perPolicy",
|
|
514
539
|
// apiInput key: derives from the API's OWN payload. Validated
|
|
515
540
|
// before it runs, typed in the handler, and the policy is only
|
|
516
541
|
// referable from APIs whose input schema carries `email`.
|
|
@@ -521,14 +546,14 @@ const lambder = new Lambder<SessionData>({ apiPath: "/api" })
|
|
|
521
546
|
errorMessage: { type: "warning", content: "Too many attempts for this address." },
|
|
522
547
|
},
|
|
523
548
|
},
|
|
524
|
-
}
|
|
549
|
+
},
|
|
525
550
|
// 2. Idempotency: a store instance + replay defaults. May share the rate
|
|
526
551
|
// limiter's table (records use an IDEM# key prefix).
|
|
527
|
-
|
|
552
|
+
idempotency: {
|
|
528
553
|
store: new LambderDdbIdempotency({ tableName: "app-rate-limiter", region: "us-east-1" }),
|
|
529
554
|
defaultTtlSeconds: 24 * 3600,
|
|
530
555
|
failOpen: true, // DynamoDB down => execute without dedupe instead of failing
|
|
531
|
-
}
|
|
556
|
+
},
|
|
532
557
|
// 3. Named guards. Input modes: apiInput checks a slice of the API's own
|
|
533
558
|
// payload (the schema keeps the field; the guard is declarable only
|
|
534
559
|
// where the payload type passes both); guardInput is the guard's OWN
|
|
@@ -539,7 +564,7 @@ const lambder = new Lambder<SessionData>({ apiPath: "/api" })
|
|
|
539
564
|
// (session: true, declarable only on addSessionApi), take a per-API
|
|
540
565
|
// PARAM (annotate a 4th handler argument), and RETURN a value that
|
|
541
566
|
// lands typed on the API handler's ctx.guardData[name].
|
|
542
|
-
|
|
567
|
+
guards: {
|
|
543
568
|
captcha: lambderGuard({
|
|
544
569
|
guardInput: z.object({ captchaToken: z.string() }),
|
|
545
570
|
handler: async (ctx, { captchaToken }) => {
|
|
@@ -557,7 +582,8 @@ const lambder = new Lambder<SessionData>({ apiPath: "/api" })
|
|
|
557
582
|
handler: (ctx, _payload, _res, permission: PermissionString) =>
|
|
558
583
|
requirePermissionOrRefuse(ctx.session, permission), // return value → ctx.guardData.orgPermission
|
|
559
584
|
}),
|
|
560
|
-
}
|
|
585
|
+
},
|
|
586
|
+
});
|
|
561
587
|
|
|
562
588
|
lambder.addApi("public.resetPassword", {
|
|
563
589
|
// captchaToken is NOT declared here: it travels in the separate
|
|
@@ -566,16 +592,20 @@ lambder.addApi("public.resetPassword", {
|
|
|
566
592
|
// in apiInput mode against the API's own payload.
|
|
567
593
|
input: z.object({ email: z.string().email() }),
|
|
568
594
|
output: z.object({ ok: z.boolean() }),
|
|
569
|
-
rateLimit: ["authPerIp", "codePerEmail"], // stacked: checked in order, first exceeded refuses (429 envelope)
|
|
595
|
+
rateLimit: ["authPerIp", "codePerEmail"], // stacked: checked in order, first exceeded refuses (429 envelope + Retry-After)
|
|
570
596
|
guards: "captcha", // one name, a list of names, or a { name: param } map
|
|
571
597
|
}, handler);
|
|
572
598
|
|
|
573
599
|
lambder.addSessionApi("secure.order.create", {
|
|
574
600
|
input: OrderSchema,
|
|
575
601
|
output: OrderResultSchema,
|
|
576
|
-
|
|
602
|
+
// Map form: tune a perApi policy for this API. Overrides merge over the
|
|
603
|
+
// policy's windows (perMin here, the policy's other windows still apply)
|
|
604
|
+
// and errorMessage is overridable too. Window overrides on a perPolicy
|
|
605
|
+
// policy are a startup error: one shared counter has one set of limits.
|
|
606
|
+
rateLimit: { writePerUser: { perMin: 10 } },
|
|
577
607
|
guards: { orgPermission: "ORDERS.CREATE" }, // param typed per guard; entries run in insertion order
|
|
578
|
-
idempotency: true, // or { ttlSeconds: 3600 }; type error
|
|
608
|
+
idempotency: true, // or { ttlSeconds: 3600 }; type error unless created with idempotency
|
|
579
609
|
}, async (ctx, res) => {
|
|
580
610
|
const { organizationId } = ctx.guardData.orgPermission; // typed guard output
|
|
581
611
|
// ...
|
|
@@ -584,18 +614,32 @@ lambder.addSessionApi("secure.order.create", {
|
|
|
584
614
|
|
|
585
615
|
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.
|
|
586
616
|
|
|
587
|
-
For api modules split across files,
|
|
617
|
+
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):
|
|
588
618
|
|
|
589
619
|
```typescript
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
}
|
|
620
|
+
// app.ts: declarations + the fully configured instance
|
|
621
|
+
export const lambderApp = initLambder<SessionData>().create({
|
|
622
|
+
apiPath: "/api",
|
|
623
|
+
session: { tableName: "app-session", tableRegion: "us-east-1", sessionSalt: "..." },
|
|
624
|
+
rateLimits: { limiter, policies: apiRateLimitPolicies },
|
|
625
|
+
idempotency: { store: idempotencyStore },
|
|
626
|
+
guards: apiGuards,
|
|
627
|
+
});
|
|
628
|
+
export type AppLambder = typeof lambderApp;
|
|
629
|
+
|
|
630
|
+
// orders.ts: an api module
|
|
595
631
|
export const orderApi = (lambder: AppLambder) => lambder.addSessionApi(...);
|
|
632
|
+
|
|
633
|
+
// index.ts: registration only (hooks, routes, modules)
|
|
634
|
+
const lambder = lambderApp.addHook(...).use(orderApi)...;
|
|
635
|
+
export const handler = lambder.getHandler();
|
|
596
636
|
```
|
|
597
637
|
|
|
598
|
-
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), so the caller's `errorMessageHandler` surfaces them with zero client code.
|
|
638
|
+
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).
|
|
639
|
+
|
|
640
|
+
**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.
|
|
641
|
+
|
|
642
|
+
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.
|
|
599
643
|
|
|
600
644
|
**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 (the same scheme as LambderDdbCache; `compressionQuality` on the store, default 5): 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.
|
|
601
645
|
|
|
@@ -60,6 +60,8 @@ export type LambderApiOutcome<T> = {
|
|
|
60
60
|
status?: number;
|
|
61
61
|
/** Envelope errorMessage, when the server provided one. */
|
|
62
62
|
errorMessage?: any;
|
|
63
|
+
/** Seconds to wait before retrying, from the response's Retry-After header (rate-limit refusals send it). */
|
|
64
|
+
retryAfterSeconds?: number;
|
|
63
65
|
/** Underlying Error for network/timeout/server/unknown failures. */
|
|
64
66
|
error?: Error;
|
|
65
67
|
/** Zod issue detail for 'validation'. */
|
|
@@ -222,6 +222,10 @@ export default class LambderCaller {
|
|
|
222
222
|
}
|
|
223
223
|
return { ok: false, reason: 'validation', status: res.status, zodError };
|
|
224
224
|
}
|
|
225
|
+
// Retry-After (delta-seconds) rides every refusal that knows its
|
|
226
|
+
// reset time, e.g. a rate limit; absent or unreadable is undefined.
|
|
227
|
+
const retryAfterValue = Number(res.headers.get("retry-after") ?? NaN);
|
|
228
|
+
const retryAfter = Number.isFinite(retryAfterValue) && retryAfterValue >= 0 ? { retryAfterSeconds: retryAfterValue } : {};
|
|
225
229
|
let data;
|
|
226
230
|
try {
|
|
227
231
|
data = await res.json();
|
|
@@ -248,7 +252,7 @@ export default class LambderCaller {
|
|
|
248
252
|
else {
|
|
249
253
|
await reportError(new Error("Version Expired; Please refresh;"));
|
|
250
254
|
}
|
|
251
|
-
return { ok: false, reason: 'versionExpired', status: res.status, errorMessage: data.errorMessage, response: data };
|
|
255
|
+
return { ok: false, reason: 'versionExpired', status: res.status, errorMessage: data.errorMessage, response: data, ...retryAfter };
|
|
252
256
|
}
|
|
253
257
|
if (data.sessionExpired) {
|
|
254
258
|
this.clearSessionCookies();
|
|
@@ -258,7 +262,7 @@ export default class LambderCaller {
|
|
|
258
262
|
else {
|
|
259
263
|
await reportError(new Error("Session Expired; Please log in again;"));
|
|
260
264
|
}
|
|
261
|
-
return { ok: false, reason: 'sessionExpired', status: res.status, errorMessage: data.errorMessage, response: data };
|
|
265
|
+
return { ok: false, reason: 'sessionExpired', status: res.status, errorMessage: data.errorMessage, response: data, ...retryAfter };
|
|
262
266
|
}
|
|
263
267
|
if (data.notAuthorized) {
|
|
264
268
|
if (notAuthorizedHandler) {
|
|
@@ -267,7 +271,7 @@ export default class LambderCaller {
|
|
|
267
271
|
else {
|
|
268
272
|
await reportError(new Error("Not Authorized;"));
|
|
269
273
|
}
|
|
270
|
-
return { ok: false, reason: 'notAuthorized', status: res.status, errorMessage: data.errorMessage, response: data };
|
|
274
|
+
return { ok: false, reason: 'notAuthorized', status: res.status, errorMessage: data.errorMessage, response: data, ...retryAfter };
|
|
271
275
|
}
|
|
272
276
|
if (data.message && messageHandler) {
|
|
273
277
|
await messageHandler(data.message);
|
|
@@ -276,7 +280,7 @@ export default class LambderCaller {
|
|
|
276
280
|
if (errorMessageHandler) {
|
|
277
281
|
await errorMessageHandler(data.errorMessage);
|
|
278
282
|
}
|
|
279
|
-
return { ok: false, reason: 'errorMessage', status: res.status, errorMessage: data.errorMessage, response: data };
|
|
283
|
+
return { ok: false, reason: 'errorMessage', status: res.status, errorMessage: data.errorMessage, response: data, ...retryAfter };
|
|
280
284
|
}
|
|
281
285
|
return { ok: true, payload: data.payload, response: data };
|
|
282
286
|
}
|
package/dist/core/Lambder.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { type LambderSessionDataRefreshConfig } from "../session/LambderSessionM
|
|
|
9
9
|
import LambderSessionController, { type LambderSessionCookieOptions } from "../session/LambderSessionController.js";
|
|
10
10
|
import { type LambderPublicFilesOptions } from "./LambderPublicFiles.js";
|
|
11
11
|
import type { LambderApiGuard, LambderGuardMetaMap, LambderGuardsOption, LambderGuardDataOf, LambderGuardInputsOf } from "../policies/LambderApiGuards.js";
|
|
12
|
-
import type { LambderApiRateLimitPolicyConfig, LambderApiRateLimitsConfig,
|
|
12
|
+
import type { LambderApiRateLimitPolicyConfig, LambderApiRateLimitsConfig, LambderRateLimitOption } from "../policies/LambderApiRateLimits.js";
|
|
13
13
|
import type { LambderApiIdempotencyConfig } from "../policies/LambderApiIdempotency.js";
|
|
14
14
|
import type { MergeContract } from "../shared/LambderApiContract.js";
|
|
15
15
|
import { type LambderHttpEvent, type LambderRenderContext, type LambderSessionRenderContext } from "./LambderContext.js";
|
|
@@ -64,7 +64,40 @@ export type LambderHandler = {
|
|
|
64
64
|
(event: LambderHttpEvent, context: Context): Promise<LambderHttpResponse>;
|
|
65
65
|
(event: unknown, context: Context): Promise<unknown>;
|
|
66
66
|
};
|
|
67
|
-
|
|
67
|
+
/** DynamoDB session configuration (the `session` option of create/new). */
|
|
68
|
+
export type LambderSessionOptions<TSessionData = any> = {
|
|
69
|
+
tableName: string;
|
|
70
|
+
tableRegion: string;
|
|
71
|
+
sessionSalt: string;
|
|
72
|
+
enableSlidingExpiration?: boolean;
|
|
73
|
+
/** Min seconds between sliding-expiration writes. Default: max(60, 5% of TTL). */
|
|
74
|
+
slidingWriteIntervalSeconds?: number;
|
|
75
|
+
/** Session cookie attributes, e.g. { domain: ".example.com" } for cross-subdomain sessions. `domain` may be a (hostname) => string function for multi-domain deployments. */
|
|
76
|
+
cookie?: LambderSessionCookieOptions;
|
|
77
|
+
/** Session cookie names. Defaults: LMDRSESSIONTKID / LMDRSESSIONCSTK. */
|
|
78
|
+
tokenCookieKey?: string;
|
|
79
|
+
csrfCookieKey?: string;
|
|
80
|
+
partitionKey?: string;
|
|
81
|
+
sortKey?: string;
|
|
82
|
+
/**
|
|
83
|
+
* Opt-in freshness for session.data derived from external state (roles,
|
|
84
|
+
* permissions, feature flags...). Every session read renews data past
|
|
85
|
+
* its ttlSeconds via your refresh callback, persisting in place on the
|
|
86
|
+
* same record: same tokens, same cookies. Return null from refresh to
|
|
87
|
+
* end the session. See LambderSessionDataRefreshConfig for the exact
|
|
88
|
+
* semantics.
|
|
89
|
+
*/
|
|
90
|
+
dataRefresh?: LambderSessionDataRefreshConfig<TSessionData>;
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* Everything an instance is configured with, in ONE declaration: base
|
|
94
|
+
* serving options plus the type-affecting policy layer (rate limits,
|
|
95
|
+
* guards, idempotency) and session/CORS config. There are no enable/define
|
|
96
|
+
* chain methods; the instance is born fully configured and fully typed
|
|
97
|
+
* (via initLambder), so no ordering rules exist and no partially-configured
|
|
98
|
+
* instance type ever needs a name.
|
|
99
|
+
*/
|
|
100
|
+
export type LambderCreateOptions<TSessionData = any> = {
|
|
68
101
|
publicPath?: string;
|
|
69
102
|
apiPath?: string;
|
|
70
103
|
apiVersion?: string;
|
|
@@ -76,21 +109,34 @@ export type LambderConstructorOptions = {
|
|
|
76
109
|
etag?: boolean;
|
|
77
110
|
/** Guard threshold for Lambda's ~6MB response cap. Default: 5,500,000. */
|
|
78
111
|
maxResponseBytes?: number;
|
|
112
|
+
/** CORS: true allows any origin; or pass a LambderCorsConfig. Default: off. */
|
|
113
|
+
cors?: boolean | LambderCorsConfig;
|
|
114
|
+
/** DynamoDB-backed sessions; required for addSessionApi/addSessionRoute. */
|
|
115
|
+
session?: LambderSessionOptions<TSessionData>;
|
|
116
|
+
/** Declarative per-API rate limiting: your limiter plus named policies APIs reference (typed) via the `rateLimit` option. */
|
|
117
|
+
rateLimits?: LambderApiRateLimitsConfig<Record<string, LambderApiRateLimitPolicyConfig>>;
|
|
118
|
+
/** Named guards APIs reference (typed) via the `guards` option; build each with lambderGuard(). */
|
|
119
|
+
guards?: Record<string, LambderApiGuard<any, any, any>>;
|
|
120
|
+
/** Declarative idempotency: your store plus replay defaults; APIs opt in via `idempotency: true | { ttlSeconds }`. */
|
|
121
|
+
idempotency?: LambderApiIdempotencyConfig;
|
|
79
122
|
};
|
|
80
123
|
/**
|
|
81
|
-
* Main Lambder class for building type-safe serverless APIs
|
|
124
|
+
* Main Lambder class for building type-safe serverless APIs. Create
|
|
125
|
+
* instances with initLambder<SessionData>().create({...}) (see below): the
|
|
126
|
+
* whole configuration, including the typed policy layer, is given at
|
|
127
|
+
* construction, and only registration (routes, apis, hooks, use) chains.
|
|
82
128
|
*
|
|
83
129
|
* @typeParam TSessionData - Type of session data stored in DynamoDB
|
|
84
130
|
* @typeParam _TContract - @internal Accumulates API contract during chaining (do not pass manually)
|
|
85
|
-
* @typeParam _TRateLimitPolicies - @internal
|
|
86
|
-
* @typeParam _TGuards - @internal Guard
|
|
87
|
-
* @typeParam _TIdempotencyEnabled - @internal
|
|
131
|
+
* @typeParam _TRateLimitPolicies - @internal Inferred from create()'s rateLimits.policies (do not pass manually)
|
|
132
|
+
* @typeParam _TGuards - @internal Guard metadata map inferred from create()'s guards (do not pass manually)
|
|
133
|
+
* @typeParam _TIdempotencyEnabled - @internal True when create() received idempotency (do not pass manually)
|
|
88
134
|
*
|
|
89
135
|
* @example
|
|
90
136
|
* ```typescript
|
|
91
137
|
* interface SessionData { userId: string; role: string; }
|
|
92
138
|
*
|
|
93
|
-
* const lambder =
|
|
139
|
+
* const lambder = initLambder<SessionData>().create({ apiPath: '/api' })
|
|
94
140
|
* .addApi('getUser', { input: z.object({...}), output: z.object({...}) }, handler)
|
|
95
141
|
* .addApi('createUser', { input: z.object({...}), output: z.object({...}) }, handler);
|
|
96
142
|
* ```
|
|
@@ -130,30 +176,7 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
130
176
|
private sessionCookieOptions;
|
|
131
177
|
private sessionTokenCookieKey;
|
|
132
178
|
private sessionCsrfCookieKey;
|
|
133
|
-
constructor(options?:
|
|
134
|
-
enableCors(config: boolean | LambderCorsConfig): this;
|
|
135
|
-
enableDdbSession({ tableName, tableRegion, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, cookie, partitionKey, sortKey, dataRefresh, }: {
|
|
136
|
-
tableName: string;
|
|
137
|
-
tableRegion: string;
|
|
138
|
-
sessionSalt: string;
|
|
139
|
-
enableSlidingExpiration?: boolean;
|
|
140
|
-
/** Min seconds between sliding-expiration writes. Default: max(60, 5% of TTL). */
|
|
141
|
-
slidingWriteIntervalSeconds?: number;
|
|
142
|
-
/** Session cookie attributes, e.g. { domain: ".example.com" } for cross-subdomain sessions. `domain` may be a (hostname) => string function for multi-domain deployments. */
|
|
143
|
-
cookie?: LambderSessionCookieOptions;
|
|
144
|
-
partitionKey?: string;
|
|
145
|
-
sortKey?: string;
|
|
146
|
-
/**
|
|
147
|
-
* Opt-in freshness for session.data derived from external state
|
|
148
|
-
* (roles, permissions, feature flags...). Every session read
|
|
149
|
-
* renews data past its ttlSeconds via your refresh callback,
|
|
150
|
-
* persisting in place on the same record: same tokens, same
|
|
151
|
-
* cookies. Return null from refresh to end the session. See
|
|
152
|
-
* LambderSessionDataRefreshConfig for the exact semantics.
|
|
153
|
-
*/
|
|
154
|
-
dataRefresh?: LambderSessionDataRefreshConfig<TSessionData>;
|
|
155
|
-
}): this;
|
|
156
|
-
setSessionCookieKey(sessionTokenCookieKey: string, sessionCsrfCookieKey: string): this;
|
|
179
|
+
constructor(options?: LambderCreateOptions<TSessionData>);
|
|
157
180
|
setRouteFallbackHandler(routeFallbackHandler: FallbackHandlerFunction): this;
|
|
158
181
|
setApiFallbackHandler(apiFallbackHandler: FallbackHandlerFunction): this;
|
|
159
182
|
setApiInputValidationErrorHandler(apiInputValidationErrorHandler: ApiInputValidationErrorHandlerFunction): this;
|
|
@@ -181,40 +204,14 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
181
204
|
serveIndexHtml(handler?: FallbackHandlerFunction, options?: LambderIndexHtmlOptions): this;
|
|
182
205
|
/** Apply the serveIndexHtml gates; null means fall through. */
|
|
183
206
|
private tryServeIndexHtml;
|
|
207
|
+
private getOrCreatePolicyEngine;
|
|
184
208
|
/**
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
* returned type narrows so only declared names are accepted, and
|
|
190
|
-
* policies keyed per "session" are only referable from addSessionApi.
|
|
191
|
-
* Callable once; call it before the API registrations that use it.
|
|
192
|
-
*/
|
|
193
|
-
enableApiRateLimits<const TPolicies extends Record<string, LambderApiRateLimitPolicyConfig>>(config: LambderApiRateLimitsConfig<TPolicies>): Lambder<TSessionData, _TContract, TPolicies, _TGuards, _TIdempotencyEnabled>;
|
|
194
|
-
/**
|
|
195
|
-
* Wire declarative idempotency: your LambderDdbIdempotency instance plus
|
|
196
|
-
* replay defaults. APIs opt in via `idempotency: true | { ttlSeconds }`;
|
|
197
|
-
* the option is a type error until this is called. Requests carrying a
|
|
198
|
-
* client `idempotencyKey` (sent by LambderCaller) claim an
|
|
199
|
-
* identity+api+key scope atomically: concurrent duplicates refuse with
|
|
200
|
-
* 409, replays of a completed request return the stored response, and a
|
|
201
|
-
* crashed original releases its claim. Callable once.
|
|
202
|
-
*/
|
|
203
|
-
enableApiIdempotency(config: LambderApiIdempotencyConfig): Lambder<TSessionData, _TContract, _TRateLimitPolicies, _TGuards, true>;
|
|
204
|
-
/**
|
|
205
|
-
* Define named guards that APIs reference (typed) via the `guards`
|
|
206
|
-
* option. Each guard is built with lambderGuard(): its input mode
|
|
207
|
-
* (apiInput slice of the API's own payload, a separate client-sent
|
|
208
|
-
* guardInput, or none), an optional `session: true` requirement, an
|
|
209
|
-
* optional parameter APIs pass in their declaration (`guards: { name:
|
|
210
|
-
* param }`), and an optional return value that lands typed on the
|
|
211
|
-
* handler's ctx.guardData[name]. Guards run before input validation, in
|
|
212
|
-
* the order the API declares them; a handler refuses by throwing
|
|
213
|
-
* (typically refuse()). Callable multiple times so domain modules can
|
|
214
|
-
* contribute their own; names must not collide.
|
|
209
|
+
* The response for a rejected input: the app's
|
|
210
|
+
* setApiInputValidationErrorHandler when set, otherwise the standard 422
|
|
211
|
+
* body. The API's own schema and every preflight slice (guard inputs,
|
|
212
|
+
* rate-limit keys) answer through here, so one failure has one shape.
|
|
215
213
|
*/
|
|
216
|
-
|
|
217
|
-
private getOrCreatePolicyEngine;
|
|
214
|
+
private inputValidationRefusal;
|
|
218
215
|
/** Registration-time checks shared by addApi/addSessionApi. */
|
|
219
216
|
private assertApiRegistration;
|
|
220
217
|
addRoute<TPath extends Path>(condition: TPath, actionFn: (ctx: LambderRenderContext<any, PathParamsOf<TPath>>, resolver: LambderResolver) => MaybePromise<LambderResponse>): this;
|
|
@@ -222,28 +219,28 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
222
219
|
addSessionRoute<TPath extends Path>(condition: TPath, actionFn: (ctx: LambderSessionRenderContext<any, TSessionData, PathParamsOf<TPath>>, resolver: LambderResolver) => MaybePromise<LambderResponse>): this;
|
|
223
220
|
addSessionRoute(condition: RegExp | ConditionFunction | LambderRouteMatcher, actionFn: SessionActionFunction<TSessionData>): this;
|
|
224
221
|
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, _TGuards, _TIdempotencyEnabled>;
|
|
225
|
-
addApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny, const TRateOpt extends
|
|
222
|
+
addApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny, const TRateOpt extends LambderRateLimitOption<_TRateLimitPolicies, z.infer<TInput>, false> = never, const TGuardsOpt extends LambderGuardsOption<_TGuards, z.infer<TInput>, false> = never>(name: TName, schema: {
|
|
226
223
|
input: TInput;
|
|
227
224
|
output: TOutput;
|
|
228
225
|
} & {
|
|
229
|
-
/** Named rate limits, checked in declared order before guards and validation
|
|
226
|
+
/** Named rate limits, checked in declared order before guards and validation: a name, a list of names, or a { name: true | override } map (windows overridable on perApi budgets, errorMessage on any). The first exceeded one refuses (429 envelope + Retry-After); attempts count on every counter checked before it. */
|
|
230
227
|
rateLimit?: TRateOpt;
|
|
231
228
|
/** Named guards, run in declared order before input validation: a name, a list of names, or a { name: param } map for parameterized guards. Their input requirements merge into this API's contract input; their return values land typed on ctx.guardData. */
|
|
232
229
|
guards?: TGuardsOpt;
|
|
233
|
-
/** Replay-protect this API per client idempotencyKey. Requires
|
|
230
|
+
/** Replay-protect this API per client idempotencyKey. Requires the idempotency option at creation. */
|
|
234
231
|
idempotency?: _TIdempotencyEnabled extends true ? (boolean | {
|
|
235
232
|
ttlSeconds?: number;
|
|
236
233
|
}) : never;
|
|
237
234
|
}, handler: (ctx: LambderRenderContext<z.infer<TInput>, Record<string, string>, LambderGuardDataOf<_TGuards, TGuardsOpt>>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>, LambderGuardInputsOf<_TGuards, TGuardsOpt>>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled>;
|
|
238
|
-
addSessionApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny, const TRateOpt extends
|
|
235
|
+
addSessionApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny, const TRateOpt extends LambderRateLimitOption<_TRateLimitPolicies, z.infer<TInput>, true> = never, const TGuardsOpt extends LambderGuardsOption<_TGuards, z.infer<TInput>, true> = never>(name: TName, schema: {
|
|
239
236
|
input: TInput;
|
|
240
237
|
output: TOutput;
|
|
241
238
|
} & {
|
|
242
|
-
/** Named rate limits, checked in declared order before guards and validation
|
|
239
|
+
/** Named rate limits, checked in declared order before guards and validation: a name, a list of names, or a { name: true | override } map (windows overridable on perApi budgets, errorMessage on any). The first exceeded one refuses (429 envelope + Retry-After); attempts count on every counter checked before it. */
|
|
243
240
|
rateLimit?: TRateOpt;
|
|
244
241
|
/** Named guards, run in declared order before input validation: a name, a list of names, or a { name: param } map for parameterized guards. Their input requirements merge into this API's contract input; their return values land typed on ctx.guardData. */
|
|
245
242
|
guards?: TGuardsOpt;
|
|
246
|
-
/** Replay-protect this API per client idempotencyKey. Requires
|
|
243
|
+
/** Replay-protect this API per client idempotencyKey. Requires the idempotency option at creation. */
|
|
247
244
|
idempotency?: _TIdempotencyEnabled extends true ? (boolean | {
|
|
248
245
|
ttlSeconds?: number;
|
|
249
246
|
}) : never;
|
|
@@ -293,25 +290,42 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
293
290
|
render(event: LambderHttpEvent, lambdaContext: Context): Promise<LambderHttpResponse>;
|
|
294
291
|
}
|
|
295
292
|
/**
|
|
296
|
-
* The
|
|
297
|
-
*
|
|
298
|
-
*
|
|
293
|
+
* The canonical way to create an instance: fix the session data type first,
|
|
294
|
+
* then create with the full configuration in one declaration; the policy,
|
|
295
|
+
* guard, and idempotency types are INFERRED from the options, so the
|
|
296
|
+
* instance is born fully typed and `typeof lambderApp` is the annotation
|
|
297
|
+
* type for api modules. No enable/define chain exists, so there are no
|
|
298
|
+
* ordering rules and nothing can be half-configured.
|
|
299
299
|
*
|
|
300
300
|
* ```typescript
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
* }
|
|
301
|
+
* // app.ts (imports no api modules, so modules can import the type back)
|
|
302
|
+
* export const lambderApp = initLambder<SessionData>().create({
|
|
303
|
+
* apiPath: "/api",
|
|
304
|
+
* session: { tableName: "app-session", tableRegion: "us-east-1", sessionSalt: "..." },
|
|
305
|
+
* rateLimits: { limiter, policies },
|
|
306
|
+
* guards,
|
|
307
|
+
* idempotency: { store },
|
|
308
|
+
* });
|
|
309
|
+
* export type AppLambder = typeof lambderApp;
|
|
310
|
+
*
|
|
311
|
+
* // orders.ts
|
|
312
|
+
* export const orderApi = (lambder: AppLambder) => lambder.addSessionApi(...);
|
|
313
|
+
*
|
|
314
|
+
* // index.ts: registration only
|
|
315
|
+
* const lambder = lambderApp.addHook(...).use(orderApi)...;
|
|
316
|
+
* export const handler = lambder.getHandler();
|
|
306
317
|
* ```
|
|
307
318
|
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
319
|
+
* Why curried (`initLambder<S>().create(...)` rather than
|
|
320
|
+
* `new Lambder<S>(...)`): TypeScript type arguments are all-or-nothing per
|
|
321
|
+
* call, so explicitly passing the session data type to the constructor
|
|
322
|
+
* would silently WIDEN the inferred policy and guard types to their {}
|
|
323
|
+
* defaults. Fixing the session type in the first call lets the second call
|
|
324
|
+
* infer everything else from the options. `new Lambder(options)` remains
|
|
325
|
+
* for untyped or session-data-free instances.
|
|
312
326
|
*/
|
|
313
|
-
export
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
idempotency
|
|
317
|
-
}
|
|
327
|
+
export declare const initLambder: <TSessionData = any>() => {
|
|
328
|
+
create<const TOptions extends LambderCreateOptions<TSessionData>>(options: TOptions): Lambder<TSessionData, {}, TOptions["rateLimits"] extends {
|
|
329
|
+
policies: infer TPolicies extends Record<string, LambderApiRateLimitPolicyConfig>;
|
|
330
|
+
} ? TPolicies : {}, TOptions["guards"] extends Record<string, LambderApiGuard<any, any, any>> ? LambderGuardMetaMap<TOptions["guards"]> : {}, TOptions["idempotency"] extends LambderApiIdempotencyConfig ? true : false>;
|
|
331
|
+
};
|