lambder 3.7.1 → 4.0.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 +92 -24
- package/dist/{LambderCaller.d.ts → client/LambderCaller.d.ts} +50 -14
- package/dist/{LambderCaller.js → client/LambderCaller.js} +29 -17
- package/dist/{LambderMSW.d.ts → client/LambderMSW.d.ts} +1 -1
- package/dist/client.d.ts +16 -0
- package/dist/client.js +17 -0
- package/dist/{Lambder.d.ts → core/Lambder.d.ts} +45 -19
- package/dist/{Lambder.js → core/Lambder.js} +28 -12
- package/dist/{LambderContext.d.ts → core/LambderContext.d.ts} +9 -3
- package/dist/{LambderContext.js → core/LambderContext.js} +1 -0
- package/dist/{LambderPublicFiles.js → core/LambderPublicFiles.js} +1 -1
- package/dist/{LambderResponse.js → core/LambderResponse.js} +1 -1
- package/dist/{LambderResponseBuilder.d.ts → core/LambderResponseBuilder.d.ts} +3 -15
- package/dist/{LambderResponseBuilder.js → core/LambderResponseBuilder.js} +2 -2
- package/dist/{LambderTemplatingEngine.d.ts → core/LambderTemplatingEngine.d.ts} +1 -1
- package/dist/{LambderTemplatingEngine.js → core/LambderTemplatingEngine.js} +2 -2
- package/dist/index.d.ts +36 -35
- package/dist/index.js +21 -20
- package/dist/policies/LambderApiGuards.d.ts +221 -0
- package/dist/policies/LambderApiGuards.js +79 -0
- package/dist/policies/LambderApiIdempotency.d.ts +58 -0
- package/dist/policies/LambderApiIdempotency.js +215 -0
- package/dist/policies/LambderApiPolicies.d.ts +40 -0
- package/dist/policies/LambderApiPolicies.js +47 -0
- package/dist/policies/LambderApiRateLimits.d.ts +90 -0
- package/dist/policies/LambderApiRateLimits.js +77 -0
- package/dist/{LambderSessionController.d.ts → session/LambderSessionController.d.ts} +2 -1
- package/dist/{LambderSessionController.js → session/LambderSessionController.js} +16 -11
- package/dist/{LambderSessionManager.d.ts → session/LambderSessionManager.d.ts} +38 -4
- package/dist/{LambderSessionManager.js → session/LambderSessionManager.js} +49 -16
- package/dist/shared/LambderApiContract.d.ts +41 -0
- package/dist/{LambderApiError.d.ts → shared/LambderApiError.d.ts} +1 -1
- package/dist/{LambderDdbCache.js → stores/LambderDdbCache.js} +7 -38
- package/dist/stores/LambderDdbCompression.d.ts +3 -0
- package/dist/stores/LambderDdbCompression.js +39 -0
- package/dist/{LambderDdbIdempotency.d.ts → stores/LambderDdbIdempotency.d.ts} +41 -12
- package/dist/{LambderDdbIdempotency.js → stores/LambderDdbIdempotency.js} +100 -11
- package/dist/{LambderDdbRateLimiter.js → stores/LambderDdbRateLimiter.js} +5 -1
- package/dist/testing.d.ts +9 -0
- package/dist/testing.js +8 -0
- package/package.json +19 -1
- package/dist/LambderApiContract.d.ts +0 -21
- package/dist/LambderApiPolicies.d.ts +0 -177
- package/dist/LambderApiPolicies.js +0 -219
- /package/dist/{LambderMSW.js → client/LambderMSW.js} +0 -0
- /package/dist/{LambderCors.d.ts → core/LambderCors.d.ts} +0 -0
- /package/dist/{LambderCors.js → core/LambderCors.js} +0 -0
- /package/dist/{LambderPublicFiles.d.ts → core/LambderPublicFiles.d.ts} +0 -0
- /package/dist/{LambderResolver.d.ts → core/LambderResolver.d.ts} +0 -0
- /package/dist/{LambderResolver.js → core/LambderResolver.js} +0 -0
- /package/dist/{LambderResponse.d.ts → core/LambderResponse.d.ts} +0 -0
- /package/dist/{LambderRouting.d.ts → core/LambderRouting.d.ts} +0 -0
- /package/dist/{LambderRouting.js → core/LambderRouting.js} +0 -0
- /package/dist/{LambderApiContract.js → shared/LambderApiContract.js} +0 -0
- /package/dist/{LambderApiError.js → shared/LambderApiError.js} +0 -0
- /package/dist/{LambderHtml.d.ts → shared/LambderHtml.d.ts} +0 -0
- /package/dist/{LambderHtml.js → shared/LambderHtml.js} +0 -0
- /package/dist/{LambderI18n.d.ts → shared/LambderI18n.d.ts} +0 -0
- /package/dist/{LambderI18n.js → shared/LambderI18n.js} +0 -0
- /package/dist/{node-polyfills.d.ts → shared/node-polyfills.d.ts} +0 -0
- /package/dist/{node-polyfills.js → shared/node-polyfills.js} +0 -0
- /package/dist/{LambderDdbCache.d.ts → stores/LambderDdbCache.d.ts} +0 -0
- /package/dist/{LambderDdbRateLimiter.d.ts → stores/LambderDdbRateLimiter.d.ts} +0 -0
package/Readme.md
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
|
-
# Lambder - Serverless NodeJS Web Framework (
|
|
1
|
+
# Lambder - Serverless NodeJS Web Framework (v4)
|
|
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
|
|
5
|
+
**New in v4:**
|
|
6
|
+
|
|
7
|
+
- **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 (`scope: "policy"`); 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
|
+
- **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
|
+
- **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
|
+
- **Quality of life**: the `LambderApp` alias for annotating api modules, `LambderCaller.createIdempotencyKeyScope()` for one self-rotating key per logical operation, fail-open rate limiting logs its passes.
|
|
12
|
+
|
|
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.
|
|
14
|
+
|
|
15
|
+
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.
|
|
6
16
|
|
|
7
17
|
## Features
|
|
8
18
|
|
|
@@ -35,6 +45,20 @@ npm install lambder zod
|
|
|
35
45
|
yarn add lambder zod
|
|
36
46
|
```
|
|
37
47
|
|
|
48
|
+
## Package Entry Points
|
|
49
|
+
|
|
50
|
+
The package ships three entry points; pick by where the code runs:
|
|
51
|
+
|
|
52
|
+
| Entry | Runs in | Carries |
|
|
53
|
+
|-------|---------|---------|
|
|
54
|
+
| `lambder` | Server (Lambda) | The full framework: pipeline, sessions, DDB stores, policies, plus everything from `lambder/client` |
|
|
55
|
+
| `lambder/client` | Browser and isomorphic shared code | `LambderCaller`, `LambderApiError`/`refuse`, the API contract and envelope types, `html`/`xml` tagged templates, `createLambderI18n` |
|
|
56
|
+
| `lambder/testing` | Dev and test tooling | `LambderMSW`, the MSW adapter that serves your typed contract from mock handlers |
|
|
57
|
+
|
|
58
|
+
Frontends and shared isomorphic packages should import from `lambder/client` only; the entry's module graph contains no AWS SDK, Node built-ins, or server pipeline, so the browser boundary is structural rather than left to tree-shaking.
|
|
59
|
+
|
|
60
|
+
Source layout mirrors this: `src/core/` (request pipeline), `src/policies/` (declarative rate limits, guards, idempotency), `src/session/`, `src/stores/` (DynamoDB primitives), `src/client/`, and `src/shared/` (isomorphic modules both entries re-export).
|
|
61
|
+
|
|
38
62
|
## Backend Usage
|
|
39
63
|
|
|
40
64
|
### Basic Setup
|
|
@@ -275,6 +299,10 @@ lambder
|
|
|
275
299
|
|
|
276
300
|
See [docs/DYNAMODB_SETUP.md](docs/DYNAMODB_SETUP.md) for detailed setup instructions.
|
|
277
301
|
|
|
302
|
+
#### How the secrets are stored
|
|
303
|
+
|
|
304
|
+
The session cookie is `pkHash:secret`: `pkHash = sha256(sessionKey + sessionSalt)` and `secret` is 256 random bits. At rest the record stores only HASHES of the bearer secrets: the range key is `sha256(secret)` (so the lookup itself proves possession of the raw secret) and the CSRF token is stored as `csrfTokenHash`. The raw values exist only in the client's cookies and, transiently, on the `LambderCreatedSession` result the manager returns at creation; a read of the session table (backup leak, over-broad IAM, insider) therefore yields no usable cookies. Fast sha256 is the correct construction here rather than a password KDF: the secrets are 256-bit random, so there is nothing to brute-force, while `sessionSalt` peppers the identity-to-partition-key mapping so partition keys and cookie prefixes cannot be derived from (or linked to) known user ids.
|
|
305
|
+
|
|
278
306
|
#### Keeping session data fresh (`dataRefresh`)
|
|
279
307
|
|
|
280
308
|
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.
|
|
@@ -300,6 +328,7 @@ Semantics:
|
|
|
300
328
|
- The callback must be a pure derivation of external state: concurrent reads may run it in parallel, last write wins.
|
|
301
329
|
- Returning `null` deletes the session; the request is answered as session-expired.
|
|
302
330
|
- 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.
|
|
331
|
+
- Similarly, a DynamoDB failure while READING a session fails the request as a `LambderSessionReadError` instead of reading as "no session": answering session-expired there would make LambderCaller clear the client's cookies, turning an infra blip into a forced logout.
|
|
303
332
|
- The renewal write and the sliding-expiration write share a single DynamoDB put when both are due.
|
|
304
333
|
- Records created before `dataRefresh` was enabled renew on their first read.
|
|
305
334
|
- `updateSessionData()` marks data fresh (it was just written deliberately); `regenerateSession()` carries the old freshness stamp over.
|
|
@@ -478,11 +507,15 @@ const lambder = new Lambder<SessionData>({ apiPath: "/api" })
|
|
|
478
507
|
writePerUser: { perMin: 30, per: "session" }, // only referable from addSessionApi (also enforced at compile time)
|
|
479
508
|
codePerEmail: {
|
|
480
509
|
perMin: 3,
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
//
|
|
510
|
+
// scope "policy": ONE combined budget across every API that
|
|
511
|
+
// references this policy (send + register + reset share the
|
|
512
|
+
// 3/min). Default scope "api" gives each API its own counter.
|
|
513
|
+
scope: "policy",
|
|
514
|
+
// apiInput key: derives from the API's OWN payload. Validated
|
|
515
|
+
// before it runs, typed in the handler, and the policy is only
|
|
516
|
+
// referable from APIs whose input schema carries `email`.
|
|
484
517
|
per: lambderRateLimitKey({
|
|
485
|
-
|
|
518
|
+
apiInput: z.object({ email: z.string() }),
|
|
486
519
|
handler: (_ctx, { email }) => email.trim().toLowerCase(),
|
|
487
520
|
}),
|
|
488
521
|
errorMessage: { type: "warning", content: "Too many attempts for this address." },
|
|
@@ -496,41 +529,75 @@ const lambder = new Lambder<SessionData>({ apiPath: "/api" })
|
|
|
496
529
|
defaultTtlSeconds: 24 * 3600,
|
|
497
530
|
failOpen: true, // DynamoDB down => execute without dedupe instead of failing
|
|
498
531
|
})
|
|
499
|
-
// 3. Named guards:
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
//
|
|
532
|
+
// 3. Named guards. Input modes: apiInput checks a slice of the API's own
|
|
533
|
+
// payload (the schema keeps the field; the guard is declarable only
|
|
534
|
+
// where the payload type passes both); guardInput is the guard's OWN
|
|
535
|
+
// value, sent separately by the caller via options.guardInputs and
|
|
536
|
+
// made mandatory by the contract, so forgetting it is a compile error
|
|
537
|
+
// at the call site; or neither. Both are validated pre-run and typed
|
|
538
|
+
// in the handler. On top of that a guard may require a session
|
|
539
|
+
// (session: true, declarable only on addSessionApi), take a per-API
|
|
540
|
+
// PARAM (annotate a 4th handler argument), and RETURN a value that
|
|
541
|
+
// lands typed on the API handler's ctx.guardData[name].
|
|
504
542
|
.defineApiGuards({
|
|
505
543
|
captcha: lambderGuard({
|
|
506
|
-
|
|
544
|
+
guardInput: z.object({ captchaToken: z.string() }),
|
|
507
545
|
handler: async (ctx, { captchaToken }) => {
|
|
508
546
|
if (!await verifyCaptcha(captchaToken, ctx.ip)) refuse("Verification failed, please retry.");
|
|
509
547
|
},
|
|
510
548
|
}),
|
|
549
|
+
deviceAuth: lambderGuard({
|
|
550
|
+
apiInput: z.object({ deviceToken: z.string() }),
|
|
551
|
+
// Returns a value: the API handler reads ctx.guardData.deviceAuth.
|
|
552
|
+
handler: async (_ctx, { deviceToken }) => await resolveDeviceOrRefuse(deviceToken),
|
|
553
|
+
}),
|
|
554
|
+
orgPermission: lambderGuard({
|
|
555
|
+
session: true,
|
|
556
|
+
// Parameterized: APIs declare guards: { orgPermission: "SOME.PERMISSION" }.
|
|
557
|
+
handler: (ctx, _payload, _res, permission: PermissionString) =>
|
|
558
|
+
requirePermissionOrRefuse(ctx.session, permission), // return value → ctx.guardData.orgPermission
|
|
559
|
+
}),
|
|
511
560
|
});
|
|
512
561
|
|
|
513
562
|
lambder.addApi("public.resetPassword", {
|
|
514
|
-
// captchaToken is NOT declared here:
|
|
515
|
-
//
|
|
516
|
-
// sees it.
|
|
563
|
+
// captchaToken is NOT declared here: it travels in the separate
|
|
564
|
+
// guardInputs channel, so the guard validates and consumes it and the
|
|
565
|
+
// handler never sees it. `email` IS declared: the codePerEmail key runs
|
|
566
|
+
// in apiInput mode against the API's own payload.
|
|
517
567
|
input: z.object({ email: z.string().email() }),
|
|
518
568
|
output: z.object({ ok: z.boolean() }),
|
|
519
569
|
rateLimit: ["authPerIp", "codePerEmail"], // stacked: checked in order, first exceeded refuses (429 envelope)
|
|
520
|
-
guards: "captcha",
|
|
570
|
+
guards: "captcha", // one name, a list of names, or a { name: param } map
|
|
521
571
|
}, handler);
|
|
522
572
|
|
|
523
573
|
lambder.addSessionApi("secure.order.create", {
|
|
524
574
|
input: OrderSchema,
|
|
525
575
|
output: OrderResultSchema,
|
|
526
576
|
rateLimit: "writePerUser",
|
|
577
|
+
guards: { orgPermission: "ORDERS.CREATE" }, // param typed per guard; entries run in insertion order
|
|
527
578
|
idempotency: true, // or { ttlSeconds: 3600 }; type error until enableApiIdempotency()
|
|
528
|
-
},
|
|
579
|
+
}, async (ctx, res) => {
|
|
580
|
+
const { organizationId } = ctx.guardData.orgPermission; // typed guard output
|
|
581
|
+
// ...
|
|
582
|
+
});
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
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
|
+
|
|
587
|
+
For api modules split across files, annotate their `lambder` parameter with the `LambderApp` alias instead of hand-writing the instance generics:
|
|
588
|
+
|
|
589
|
+
```typescript
|
|
590
|
+
export type AppLambder = LambderApp<SessionData, {
|
|
591
|
+
policies: typeof apiRateLimitPolicies; // what enableApiRateLimits({ policies }) receives
|
|
592
|
+
guards: typeof apiGuards; // what defineApiGuards(...) receives
|
|
593
|
+
idempotency: true; // enableApiIdempotency(...) is called
|
|
594
|
+
}>;
|
|
595
|
+
export const orderApi = (lambder: AppLambder) => lambder.addSessionApi(...);
|
|
529
596
|
```
|
|
530
597
|
|
|
531
|
-
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.
|
|
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.
|
|
532
599
|
|
|
533
|
-
**Idempotency semantics**: the client sends an `idempotencyKey` per call (see LambderCaller below); generate it once per logical operation and reuse it on retries.
|
|
600
|
+
**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.
|
|
534
601
|
|
|
535
602
|
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).
|
|
536
603
|
|
|
@@ -581,12 +648,12 @@ cI18n.forLanguage("tr")("compute"); // explicit (per-request backend use)
|
|
|
581
648
|
|
|
582
649
|
## Frontend Usage with LambderCaller
|
|
583
650
|
|
|
584
|
-
LambderCaller is a frontend companion library for Lambder (only 2kb compressed) designed to simplify making type-safe API requests to your Lambder backend.
|
|
651
|
+
LambderCaller is a frontend companion library for Lambder (only 2kb compressed) designed to simplify making type-safe API requests to your Lambder backend. Import it from the `lambder/client` entry: everything reachable from there is browser-safe by construction (no AWS SDK, no Node built-ins, no server pipeline), so your bundle can never pick up server code.
|
|
585
652
|
|
|
586
653
|
### Basic Setup with Type Safety
|
|
587
654
|
|
|
588
655
|
```typescript
|
|
589
|
-
import { LambderCaller } from "lambder";
|
|
656
|
+
import { LambderCaller } from "lambder/client";
|
|
590
657
|
import type { ApiContractType } from "./backend/handler"; // Import the inferred contract type
|
|
591
658
|
|
|
592
659
|
const lambderCaller = new LambderCaller<ApiContractType>({
|
|
@@ -635,8 +702,9 @@ Every configured handler still fires on the matching failure, so global UX (toas
|
|
|
635
702
|
Also available:
|
|
636
703
|
|
|
637
704
|
- **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.
|
|
638
|
-
- **Per-call handler overrides**: every constructor handler (`errorHandler`, `sessionExpiredHandler`, `errorMessageHandler`, ...) can be overridden in the options of a single `api`/`
|
|
639
|
-
- **
|
|
705
|
+
- **Per-call handler overrides**: every constructor handler (`errorHandler`, `sessionExpiredHandler`, `errorMessageHandler`, ...) can be overridden in the options of a single `api`/`apiOutcome` call.
|
|
706
|
+
- **Guard inputs**: for APIs whose guards run in guardInput mode, pass their values per call as `guardInputs: { <guardName>: value }`; the typed contract makes the options argument (and the correct value shape) mandatory for those APIs.
|
|
707
|
+
- **Idempotency keys**: pass `idempotencyKey` per call for APIs declared idempotent on the server (see Declarative API Policies). Generate it once per logical operation with `LambderCaller.createIdempotencyKey()` (safe in insecure contexts where `crypto.randomUUID` is missing) and send the same key on retries; rotate after a confirmed success. `LambderCaller.createIdempotencyKeyScope()` packages that pattern for a component performing one operation repeatedly: read `scope.current` on every attempt, call `scope.rotate()` after a confirmed success. Keys must be unguessable random and 16-200 characters (they scope the replay record for logged-out clients); the server refuses shorter keys with a 400.
|
|
640
708
|
|
|
641
709
|
### Benefits
|
|
642
710
|
|
|
@@ -654,7 +722,7 @@ Also available:
|
|
|
654
722
|
LambderMSW provides seamless integration with [MSW (Mock Service Worker)](https://mswjs.io/) for testing your APIs with full type safety.
|
|
655
723
|
|
|
656
724
|
```typescript
|
|
657
|
-
import { LambderMSW } from 'lambder';
|
|
725
|
+
import { LambderMSW } from 'lambder/testing';
|
|
658
726
|
import { setupServer } from 'msw/node';
|
|
659
727
|
import type { ApiContractType } from './backend/handler';
|
|
660
728
|
|
|
@@ -1,6 +1,18 @@
|
|
|
1
|
-
import { LambderApiResponse } from '
|
|
2
|
-
import type { ApiContractShape } from '
|
|
1
|
+
import type { LambderApiResponse } from '../shared/LambderApiContract.js';
|
|
2
|
+
import type { ApiContractShape } from '../shared/LambderApiContract.js';
|
|
3
3
|
import type { z } from "zod";
|
|
4
|
+
type IsAny<T> = 0 extends (1 & T) ? true : false;
|
|
5
|
+
type GuardInputsOf<TEntry> = TEntry extends {
|
|
6
|
+
guardInputs: infer G;
|
|
7
|
+
} ? G : never;
|
|
8
|
+
/**
|
|
9
|
+
* The options argument: optional normally, REQUIRED (with guardInputs) when
|
|
10
|
+
* the API's contract declares guardInput-mode guards, so forgetting to send
|
|
11
|
+
* a guard's value is a compile error at the call site.
|
|
12
|
+
*/
|
|
13
|
+
type CallOptionsArg<TContract, TApiName> = IsAny<TContract> extends true ? [options?: LambderCallOptions] : TApiName extends keyof TContract ? [GuardInputsOf<TContract[TApiName]>] extends [never] ? [options?: LambderCallOptions] : [options: LambderCallOptions & {
|
|
14
|
+
guardInputs: GuardInputsOf<TContract[TApiName]>;
|
|
15
|
+
}] : [options?: LambderCallOptions];
|
|
4
16
|
type VoidFunction = () => void | Promise<void>;
|
|
5
17
|
type FetchTracker = {
|
|
6
18
|
apiName: string;
|
|
@@ -24,6 +36,13 @@ type FetchEndEventHandler = (params: {
|
|
|
24
36
|
type ErrorHandler = (err: Error) => void | Promise<void>;
|
|
25
37
|
type ValidationErrorHandler = (zodError: z.ZodError) => (void | false) | Promise<(void | false)>;
|
|
26
38
|
type MessageHandler = (message: any) => void | Promise<void>;
|
|
39
|
+
/** One logical operation's rotating idempotency key: see LambderCaller.createIdempotencyKeyScope(). */
|
|
40
|
+
export type LambderIdempotencyKeyScope = {
|
|
41
|
+
/** The key for the operation currently in progress. */
|
|
42
|
+
readonly current: string;
|
|
43
|
+
/** Call after a confirmed success: the next operation is a new intent. Returns the new key. */
|
|
44
|
+
rotate(): string;
|
|
45
|
+
};
|
|
27
46
|
export type LambderApiFailureReason = 'network' | 'timeout' | 'server' | 'validation' | 'versionExpired' | 'sessionExpired' | 'notAuthorized' | 'errorMessage' | 'unknown';
|
|
28
47
|
/**
|
|
29
48
|
* Discriminated result of an API call: `ok: true` carries the payload, every
|
|
@@ -55,12 +74,20 @@ export type LambderCallOptions = {
|
|
|
55
74
|
timeoutMs?: number;
|
|
56
75
|
/** External abort signal, combined with the timeout when both are set. */
|
|
57
76
|
signal?: AbortSignal;
|
|
77
|
+
/**
|
|
78
|
+
* Values for the API's guardInput-mode guards, keyed by guard name; sent
|
|
79
|
+
* beside the payload and consumed by the guards before validation. The
|
|
80
|
+
* typed contract makes this REQUIRED for APIs that declare such guards.
|
|
81
|
+
*/
|
|
82
|
+
guardInputs?: Record<string, unknown>;
|
|
58
83
|
/**
|
|
59
84
|
* Replay-protection key for APIs declared idempotent on the server.
|
|
60
|
-
* Generate once per logical operation
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
85
|
+
* Generate once per logical operation with createIdempotencyKey() and
|
|
86
|
+
* send the same key on retries: duplicates of an in-flight request
|
|
87
|
+
* refuse, and repeats of a completed one replay its stored response
|
|
88
|
+
* instead of re-executing. Must be UNGUESSABLE random (it scopes the
|
|
89
|
+
* replay record for logged-out clients) and at least 16 characters; the
|
|
90
|
+
* server refuses shorter keys with a 400.
|
|
64
91
|
*/
|
|
65
92
|
idempotencyKey?: string;
|
|
66
93
|
versionExpiredHandler?: VoidFunction;
|
|
@@ -111,6 +138,21 @@ export default class LambderCaller<TContract extends ApiContractShape = any> {
|
|
|
111
138
|
sessionCookieDomain?: string | ((hostname: string) => string | undefined | null);
|
|
112
139
|
});
|
|
113
140
|
setSessionCookieKey(sessionTokenCookieKey: string, sessionCsrfCookieKey: string): void;
|
|
141
|
+
/**
|
|
142
|
+
* A self-rotating idempotency key for a component or form that performs
|
|
143
|
+
* the same logical operation repeatedly. `current` is the key for the
|
|
144
|
+
* operation in progress: send it with every attempt (first try, retry
|
|
145
|
+
* after a failure, double-tap) so the server collapses them. Call
|
|
146
|
+
* `rotate()` after a confirmed success so the next operation is a new
|
|
147
|
+
* intent with its own key.
|
|
148
|
+
*
|
|
149
|
+
* ```typescript
|
|
150
|
+
* const submitKey = LambderCaller.createIdempotencyKeyScope();
|
|
151
|
+
* await caller.api("order.create", payload, { idempotencyKey: submitKey.current });
|
|
152
|
+
* submitKey.rotate();
|
|
153
|
+
* ```
|
|
154
|
+
*/
|
|
155
|
+
static createIdempotencyKeyScope(): LambderIdempotencyKeyScope;
|
|
114
156
|
/**
|
|
115
157
|
* Generate an idempotency key for one logical operation. Create it when
|
|
116
158
|
* the operation begins (a form opens, a draft starts), send the same key
|
|
@@ -127,14 +169,8 @@ export default class LambderCaller<TContract extends ApiContractShape = any> {
|
|
|
127
169
|
* Full-fidelity call: resolves to a discriminated LambderApiOutcome
|
|
128
170
|
* instead of collapsing every failure to null. Never throws.
|
|
129
171
|
*/
|
|
130
|
-
apiOutcome<TApiName extends keyof TContract & string = string, TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any>(apiName: TApiName, payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any,
|
|
131
|
-
/**
|
|
132
|
-
* Legacy shape: the parsed envelope on success (and on structured
|
|
133
|
-
* errorMessage refusals, which carry an envelope), null on every other
|
|
134
|
-
* failure. Prefer apiOutcome() when the call site needs to know why.
|
|
135
|
-
*/
|
|
136
|
-
apiRaw<TApiName extends keyof TContract & string = string, TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any>(apiName: TApiName, payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any, options?: LambderCallOptions): Promise<LambderApiResponse<TOutput> | null | undefined>;
|
|
172
|
+
apiOutcome<TApiName extends keyof TContract & string = string, TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any>(apiName: TApiName, payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any, ...rest: CallOptionsArg<TContract, TApiName>): Promise<LambderApiOutcome<TOutput>>;
|
|
137
173
|
/** Payload on success, null/undefined otherwise (indistinguishable from a null payload; prefer apiOutcome() when that matters). */
|
|
138
|
-
api<TApiName extends keyof TContract & string = string, TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any>(apiName: TApiName, payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any,
|
|
174
|
+
api<TApiName extends keyof TContract & string = string, TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any>(apiName: TApiName, payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any, ...rest: CallOptionsArg<TContract, TApiName>): Promise<TOutput | null | undefined>;
|
|
139
175
|
}
|
|
140
176
|
export {};
|
|
@@ -39,6 +39,27 @@ export default class LambderCaller {
|
|
|
39
39
|
this.sessionTokenCookieKey = sessionTokenCookieKey;
|
|
40
40
|
this.sessionCsrfCookieKey = sessionCsrfCookieKey;
|
|
41
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* A self-rotating idempotency key for a component or form that performs
|
|
44
|
+
* the same logical operation repeatedly. `current` is the key for the
|
|
45
|
+
* operation in progress: send it with every attempt (first try, retry
|
|
46
|
+
* after a failure, double-tap) so the server collapses them. Call
|
|
47
|
+
* `rotate()` after a confirmed success so the next operation is a new
|
|
48
|
+
* intent with its own key.
|
|
49
|
+
*
|
|
50
|
+
* ```typescript
|
|
51
|
+
* const submitKey = LambderCaller.createIdempotencyKeyScope();
|
|
52
|
+
* await caller.api("order.create", payload, { idempotencyKey: submitKey.current });
|
|
53
|
+
* submitKey.rotate();
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
static createIdempotencyKeyScope() {
|
|
57
|
+
let key = LambderCaller.createIdempotencyKey();
|
|
58
|
+
return {
|
|
59
|
+
get current() { return key; },
|
|
60
|
+
rotate() { key = LambderCaller.createIdempotencyKey(); return key; },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
42
63
|
/**
|
|
43
64
|
* Generate an idempotency key for one logical operation. Create it when
|
|
44
65
|
* the operation begins (a form opens, a draft starts), send the same key
|
|
@@ -149,6 +170,7 @@ export default class LambderCaller {
|
|
|
149
170
|
headers: { 'Content-Type': 'application/json', ...(headers || {}) },
|
|
150
171
|
body: JSON.stringify({
|
|
151
172
|
apiName, version, token, siteHost, payload,
|
|
173
|
+
...(options?.guardInputs !== undefined ? { guardInputs: options.guardInputs } : {}),
|
|
152
174
|
...(options?.idempotencyKey !== undefined ? { idempotencyKey: options.idempotencyKey } : {}),
|
|
153
175
|
}),
|
|
154
176
|
...(signal ? { signal } : {}),
|
|
@@ -280,25 +302,15 @@ export default class LambderCaller {
|
|
|
280
302
|
* Full-fidelity call: resolves to a discriminated LambderApiOutcome
|
|
281
303
|
* instead of collapsing every failure to null. Never throws.
|
|
282
304
|
*/
|
|
283
|
-
async apiOutcome(apiName, payload,
|
|
284
|
-
return await this.dispatch(apiName, payload,
|
|
285
|
-
}
|
|
286
|
-
;
|
|
287
|
-
/**
|
|
288
|
-
* Legacy shape: the parsed envelope on success (and on structured
|
|
289
|
-
* errorMessage refusals, which carry an envelope), null on every other
|
|
290
|
-
* failure. Prefer apiOutcome() when the call site needs to know why.
|
|
291
|
-
*/
|
|
292
|
-
async apiRaw(apiName, payload, options) {
|
|
293
|
-
const outcome = await this.dispatch(apiName, payload, options);
|
|
294
|
-
if (outcome.ok)
|
|
295
|
-
return outcome.response;
|
|
296
|
-
return outcome.reason === 'errorMessage' ? outcome.response : null;
|
|
305
|
+
async apiOutcome(apiName, payload, ...rest) {
|
|
306
|
+
return await this.dispatch(apiName, payload, rest[0]);
|
|
297
307
|
}
|
|
298
308
|
;
|
|
299
309
|
/** Payload on success, null/undefined otherwise (indistinguishable from a null payload; prefer apiOutcome() when that matters). */
|
|
300
|
-
async api(apiName, payload,
|
|
301
|
-
const
|
|
302
|
-
|
|
310
|
+
async api(apiName, payload, ...rest) {
|
|
311
|
+
const outcome = await this.dispatch(apiName, payload, rest[0]);
|
|
312
|
+
if (outcome.ok)
|
|
313
|
+
return outcome.response?.payload;
|
|
314
|
+
return outcome.reason === 'errorMessage' ? outcome.response?.payload : undefined;
|
|
303
315
|
}
|
|
304
316
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ApiContractShape } from '
|
|
1
|
+
import type { ApiContractShape } from '../shared/LambderApiContract.js';
|
|
2
2
|
type RequestHandler = any;
|
|
3
3
|
/** The parts of the msw module LambderMSW uses: `import { http, HttpResponse } from "msw"`. */
|
|
4
4
|
export type LambderMswModule = {
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser entry point (`import ... from "lambder/client"`).
|
|
3
|
+
*
|
|
4
|
+
* Everything reachable from here is browser-safe: no AWS SDK, no Node
|
|
5
|
+
* built-ins, no server pipeline. Frontends and isomorphic shared packages
|
|
6
|
+
* import from this entry so their bundles can never pick up server code;
|
|
7
|
+
* the root entry (`"lambder"`) is the server surface.
|
|
8
|
+
*/
|
|
9
|
+
export { default as LambderCaller } from "./client/LambderCaller.js";
|
|
10
|
+
export type { LambderApiOutcome, LambderApiFailureReason, LambderCallOptions, LambderIdempotencyKeyScope, } from "./client/LambderCaller.js";
|
|
11
|
+
export { LambderApiError, isLambderApiError, refuse } from "./shared/LambderApiError.js";
|
|
12
|
+
export type { LambderApiErrorOptions, LambderRefusalMessage, LambderRefuseOptions } from "./shared/LambderApiError.js";
|
|
13
|
+
export type { ApiContractShape, LambderApiResponse, LambderApiResponseConfig } from "./shared/LambderApiContract.js";
|
|
14
|
+
export { html, xml, raw, jsonScript, escapeHtml, renderHtmlValue, LambderSafeHtml, type LambderHtmlValue } from "./shared/LambderHtml.js";
|
|
15
|
+
export { createLambderI18n } from "./shared/LambderI18n.js";
|
|
16
|
+
export type { LambderLanguageMeta, LambderI18nConfig, LambderI18nInstance, LambderI18nTranslator, LambderI18nExtractParams, LambderI18nCodes, LambderI18nKeys, LambderI18nTranslatorFor, } from "./shared/LambderI18n.js";
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser entry point (`import ... from "lambder/client"`).
|
|
3
|
+
*
|
|
4
|
+
* Everything reachable from here is browser-safe: no AWS SDK, no Node
|
|
5
|
+
* built-ins, no server pipeline. Frontends and isomorphic shared packages
|
|
6
|
+
* import from this entry so their bundles can never pick up server code;
|
|
7
|
+
* the root entry (`"lambder"`) is the server surface.
|
|
8
|
+
*/
|
|
9
|
+
// The typed API caller.
|
|
10
|
+
export { default as LambderCaller } from "./client/LambderCaller.js";
|
|
11
|
+
// Typed API refusals (isomorphic: shared code may throw them from anywhere;
|
|
12
|
+
// in the browser they are plain Errors).
|
|
13
|
+
export { LambderApiError, isLambderApiError, refuse } from "./shared/LambderApiError.js";
|
|
14
|
+
// Type-safe templating (tagged templates with auto-escaping)
|
|
15
|
+
export { html, xml, raw, jsonScript, escapeHtml, renderHtmlValue, LambderSafeHtml } from "./shared/LambderHtml.js";
|
|
16
|
+
// Typed translations (standalone, isomorphic)
|
|
17
|
+
export { createLambderI18n } from "./shared/LambderI18n.js";
|
|
@@ -5,11 +5,13 @@ 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 "
|
|
9
|
-
import LambderSessionController, { type LambderSessionCookieOptions } from "
|
|
8
|
+
import { type LambderSessionDataRefreshConfig } from "../session/LambderSessionManager.js";
|
|
9
|
+
import LambderSessionController, { type LambderSessionCookieOptions } from "../session/LambderSessionController.js";
|
|
10
10
|
import { type LambderPublicFilesOptions } from "./LambderPublicFiles.js";
|
|
11
|
-
import
|
|
12
|
-
import type {
|
|
11
|
+
import type { LambderApiGuard, LambderGuardMetaMap, LambderGuardsOption, LambderGuardDataOf, LambderGuardInputsOf } from "../policies/LambderApiGuards.js";
|
|
12
|
+
import type { LambderApiRateLimitPolicyConfig, LambderApiRateLimitsConfig, LambderAllowedPolicyNames } from "../policies/LambderApiRateLimits.js";
|
|
13
|
+
import type { LambderApiIdempotencyConfig } from "../policies/LambderApiIdempotency.js";
|
|
14
|
+
import type { MergeContract } from "../shared/LambderApiContract.js";
|
|
13
15
|
import { type LambderHttpEvent, type LambderRenderContext, type LambderSessionRenderContext } from "./LambderContext.js";
|
|
14
16
|
export type { PathParamsOf, RouteCondition, ConditionFunction, LambderRouteMatcher } from "./LambderRouting.js";
|
|
15
17
|
export type { LambderCorsConfig } from "./LambderCors.js";
|
|
@@ -201,16 +203,17 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
201
203
|
enableApiIdempotency(config: LambderApiIdempotencyConfig): Lambder<TSessionData, _TContract, _TRateLimitPolicies, _TGuards, true>;
|
|
202
204
|
/**
|
|
203
205
|
* Define named guards that APIs reference (typed) via the `guards`
|
|
204
|
-
* option. Each guard is
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
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.
|
|
212
215
|
*/
|
|
213
|
-
defineApiGuards<TGuards extends Record<string, LambderApiGuard<any>>>(guards: TGuards): Lambder<TSessionData, _TContract, _TRateLimitPolicies, _TGuards &
|
|
216
|
+
defineApiGuards<TGuards extends Record<string, LambderApiGuard<any, any, any>>>(guards: TGuards): Lambder<TSessionData, _TContract, _TRateLimitPolicies, _TGuards & LambderGuardMetaMap<TGuards>, _TIdempotencyEnabled>;
|
|
214
217
|
private getOrCreatePolicyEngine;
|
|
215
218
|
/** Registration-time checks shared by addApi/addSessionApi. */
|
|
216
219
|
private assertApiRegistration;
|
|
@@ -219,32 +222,32 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
219
222
|
addSessionRoute<TPath extends Path>(condition: TPath, actionFn: (ctx: LambderSessionRenderContext<any, TSessionData, PathParamsOf<TPath>>, resolver: LambderResolver) => MaybePromise<LambderResponse>): this;
|
|
220
223
|
addSessionRoute(condition: RegExp | ConditionFunction | LambderRouteMatcher, actionFn: SessionActionFunction<TSessionData>): this;
|
|
221
224
|
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>;
|
|
222
|
-
addApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny, const TRateOpt extends
|
|
225
|
+
addApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny, const TRateOpt extends LambderAllowedPolicyNames<_TRateLimitPolicies, z.infer<TInput>, false> | readonly LambderAllowedPolicyNames<_TRateLimitPolicies, z.infer<TInput>, false>[] = never, const TGuardsOpt extends LambderGuardsOption<_TGuards, z.infer<TInput>, false> = never>(name: TName, schema: {
|
|
223
226
|
input: TInput;
|
|
224
227
|
output: TOutput;
|
|
225
228
|
} & {
|
|
226
229
|
/** Named rate limits, checked in declared order before guards and validation; the first exceeded one refuses (429 envelope). */
|
|
227
230
|
rateLimit?: TRateOpt;
|
|
228
|
-
/** Named guards, run in declared order before input validation
|
|
231
|
+
/** 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. */
|
|
229
232
|
guards?: TGuardsOpt;
|
|
230
233
|
/** Replay-protect this API per client idempotencyKey. Requires enableApiIdempotency() first. */
|
|
231
234
|
idempotency?: _TIdempotencyEnabled extends true ? (boolean | {
|
|
232
235
|
ttlSeconds?: number;
|
|
233
236
|
}) : never;
|
|
234
|
-
}, handler: (ctx: LambderRenderContext<z.infer<TInput>>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName,
|
|
235
|
-
addSessionApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny, const TRateOpt extends
|
|
237
|
+
}, 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 LambderAllowedPolicyNames<_TRateLimitPolicies, z.infer<TInput>, true> | readonly LambderAllowedPolicyNames<_TRateLimitPolicies, z.infer<TInput>, true>[] = never, const TGuardsOpt extends LambderGuardsOption<_TGuards, z.infer<TInput>, true> = never>(name: TName, schema: {
|
|
236
239
|
input: TInput;
|
|
237
240
|
output: TOutput;
|
|
238
241
|
} & {
|
|
239
242
|
/** Named rate limits, checked in declared order before guards and validation; the first exceeded one refuses (429 envelope). */
|
|
240
243
|
rateLimit?: TRateOpt;
|
|
241
|
-
/** Named guards, run in declared order before input validation
|
|
244
|
+
/** 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. */
|
|
242
245
|
guards?: TGuardsOpt;
|
|
243
246
|
/** Replay-protect this API per client idempotencyKey. Requires enableApiIdempotency() first. */
|
|
244
247
|
idempotency?: _TIdempotencyEnabled extends true ? (boolean | {
|
|
245
248
|
ttlSeconds?: number;
|
|
246
249
|
}) : never;
|
|
247
|
-
}, handler: (ctx: LambderSessionRenderContext<z.infer<TInput>, TSessionData>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName,
|
|
250
|
+
}, handler: (ctx: LambderSessionRenderContext<z.infer<TInput>, TSessionData, 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>;
|
|
248
251
|
/**
|
|
249
252
|
* Fetch the session or short-circuit the request: API calls get the
|
|
250
253
|
* protocol's { sessionExpired: true } response (handled by LambderCaller),
|
|
@@ -289,3 +292,26 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
289
292
|
private resolveRequest;
|
|
290
293
|
render(event: LambderHttpEvent, lambdaContext: Context): Promise<LambderHttpResponse>;
|
|
291
294
|
}
|
|
295
|
+
/**
|
|
296
|
+
* The app's configured Lambder instance type, for annotating the parameter
|
|
297
|
+
* of api modules used via lambder.use(...). Name the pieces you wired in
|
|
298
|
+
* index.ts and the guard metadata mapping happens for you:
|
|
299
|
+
*
|
|
300
|
+
* ```typescript
|
|
301
|
+
* export type AppLambder = LambderApp<SessionData, {
|
|
302
|
+
* policies: typeof apiRateLimitPolicies; // enableApiRateLimits({ policies })
|
|
303
|
+
* guards: typeof apiGuards; // defineApiGuards(apiGuards)
|
|
304
|
+
* idempotency: true; // enableApiIdempotency(...) was called
|
|
305
|
+
* }>;
|
|
306
|
+
* ```
|
|
307
|
+
*
|
|
308
|
+
* Every field is optional; omit what the app does not wire. The declaration
|
|
309
|
+
* is still an assertion about index.ts (registration-time asserts backstop a
|
|
310
|
+
* mismatch at cold start), but derive the fields from the same exported
|
|
311
|
+
* consts the enable calls receive and the types cannot drift.
|
|
312
|
+
*/
|
|
313
|
+
export type LambderApp<TSessionData, TConfig extends {
|
|
314
|
+
policies?: Record<string, LambderApiRateLimitPolicyConfig>;
|
|
315
|
+
guards?: Record<string, LambderApiGuard<any, any, any>>;
|
|
316
|
+
idempotency?: boolean;
|
|
317
|
+
} = {}> = Lambder<TSessionData, {}, TConfig["policies"] extends Record<string, LambderApiRateLimitPolicyConfig> ? TConfig["policies"] : {}, TConfig["guards"] extends Record<string, LambderApiGuard<any, any, any>> ? LambderGuardMetaMap<TConfig["guards"]> : {}, TConfig["idempotency"] extends true ? true : false>;
|