lambder 3.8.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 +79 -14
- package/dist/{LambderCaller.d.ts → client/LambderCaller.d.ts} +30 -12
- package/dist/{LambderCaller.js → client/LambderCaller.js} +21 -12
- 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 -20
- package/dist/{Lambder.js → core/Lambder.js} +28 -13
- 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/{LambderApiContract.d.ts → shared/LambderApiContract.d.ts} +14 -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/LambderApiPolicies.d.ts +0 -212
- package/dist/LambderApiPolicies.js +0 -228
- /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,6 +507,10 @@ 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,
|
|
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",
|
|
481
514
|
// apiInput key: derives from the API's OWN payload. Validated
|
|
482
515
|
// before it runs, typed in the handler, and the policy is only
|
|
483
516
|
// referable from APIs whose input schema carries `email`.
|
|
@@ -496,12 +529,16 @@ 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
|
|
532
|
+
// 3. Named guards. Input modes: apiInput checks a slice of the API's own
|
|
500
533
|
// payload (the schema keeps the field; the guard is declarable only
|
|
501
|
-
// where the payload type passes both)
|
|
534
|
+
// where the payload type passes both); guardInput is the guard's OWN
|
|
502
535
|
// value, sent separately by the caller via options.guardInputs and
|
|
503
536
|
// made mandatory by the contract, so forgetting it is a compile error
|
|
504
|
-
// at the call site. Both are validated pre-run and typed
|
|
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].
|
|
505
542
|
.defineApiGuards({
|
|
506
543
|
captcha: lambderGuard({
|
|
507
544
|
guardInput: z.object({ captchaToken: z.string() }),
|
|
@@ -509,6 +546,17 @@ const lambder = new Lambder<SessionData>({ apiPath: "/api" })
|
|
|
509
546
|
if (!await verifyCaptcha(captchaToken, ctx.ip)) refuse("Verification failed, please retry.");
|
|
510
547
|
},
|
|
511
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
|
+
}),
|
|
512
560
|
});
|
|
513
561
|
|
|
514
562
|
lambder.addApi("public.resetPassword", {
|
|
@@ -519,20 +567,37 @@ lambder.addApi("public.resetPassword", {
|
|
|
519
567
|
input: z.object({ email: z.string().email() }),
|
|
520
568
|
output: z.object({ ok: z.boolean() }),
|
|
521
569
|
rateLimit: ["authPerIp", "codePerEmail"], // stacked: checked in order, first exceeded refuses (429 envelope)
|
|
522
|
-
guards: "captcha",
|
|
570
|
+
guards: "captcha", // one name, a list of names, or a { name: param } map
|
|
523
571
|
}, handler);
|
|
524
572
|
|
|
525
573
|
lambder.addSessionApi("secure.order.create", {
|
|
526
574
|
input: OrderSchema,
|
|
527
575
|
output: OrderResultSchema,
|
|
528
576
|
rateLimit: "writePerUser",
|
|
577
|
+
guards: { orgPermission: "ORDERS.CREATE" }, // param typed per guard; entries run in insertion order
|
|
529
578
|
idempotency: true, // or { ttlSeconds: 3600 }; type error until enableApiIdempotency()
|
|
530
|
-
},
|
|
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(...);
|
|
531
596
|
```
|
|
532
597
|
|
|
533
|
-
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.
|
|
534
599
|
|
|
535
|
-
**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.
|
|
536
601
|
|
|
537
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).
|
|
538
603
|
|
|
@@ -583,12 +648,12 @@ cI18n.forLanguage("tr")("compute"); // explicit (per-request backend use)
|
|
|
583
648
|
|
|
584
649
|
## Frontend Usage with LambderCaller
|
|
585
650
|
|
|
586
|
-
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.
|
|
587
652
|
|
|
588
653
|
### Basic Setup with Type Safety
|
|
589
654
|
|
|
590
655
|
```typescript
|
|
591
|
-
import { LambderCaller } from "lambder";
|
|
656
|
+
import { LambderCaller } from "lambder/client";
|
|
592
657
|
import type { ApiContractType } from "./backend/handler"; // Import the inferred contract type
|
|
593
658
|
|
|
594
659
|
const lambderCaller = new LambderCaller<ApiContractType>({
|
|
@@ -637,9 +702,9 @@ Every configured handler still fires on the matching failure, so global UX (toas
|
|
|
637
702
|
Also available:
|
|
638
703
|
|
|
639
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.
|
|
640
|
-
- **Per-call handler overrides**: every constructor handler (`errorHandler`, `sessionExpiredHandler`, `errorMessageHandler`, ...) can be overridden in the options of a single `api`/`
|
|
705
|
+
- **Per-call handler overrides**: every constructor handler (`errorHandler`, `sessionExpiredHandler`, `errorMessageHandler`, ...) can be overridden in the options of a single `api`/`apiOutcome` call.
|
|
641
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.
|
|
642
|
-
- **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.
|
|
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.
|
|
643
708
|
|
|
644
709
|
### Benefits
|
|
645
710
|
|
|
@@ -657,7 +722,7 @@ Also available:
|
|
|
657
722
|
LambderMSW provides seamless integration with [MSW (Mock Service Worker)](https://mswjs.io/) for testing your APIs with full type safety.
|
|
658
723
|
|
|
659
724
|
```typescript
|
|
660
|
-
import { LambderMSW } from 'lambder';
|
|
725
|
+
import { LambderMSW } from 'lambder/testing';
|
|
661
726
|
import { setupServer } from 'msw/node';
|
|
662
727
|
import type { ApiContractType } from './backend/handler';
|
|
663
728
|
|
|
@@ -1,5 +1,5 @@
|
|
|
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
4
|
type IsAny<T> = 0 extends (1 & T) ? true : false;
|
|
5
5
|
type GuardInputsOf<TEntry> = TEntry extends {
|
|
@@ -36,6 +36,13 @@ type FetchEndEventHandler = (params: {
|
|
|
36
36
|
type ErrorHandler = (err: Error) => void | Promise<void>;
|
|
37
37
|
type ValidationErrorHandler = (zodError: z.ZodError) => (void | false) | Promise<(void | false)>;
|
|
38
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
|
+
};
|
|
39
46
|
export type LambderApiFailureReason = 'network' | 'timeout' | 'server' | 'validation' | 'versionExpired' | 'sessionExpired' | 'notAuthorized' | 'errorMessage' | 'unknown';
|
|
40
47
|
/**
|
|
41
48
|
* Discriminated result of an API call: `ok: true` carries the payload, every
|
|
@@ -75,10 +82,12 @@ export type LambderCallOptions = {
|
|
|
75
82
|
guardInputs?: Record<string, unknown>;
|
|
76
83
|
/**
|
|
77
84
|
* Replay-protection key for APIs declared idempotent on the server.
|
|
78
|
-
* Generate once per logical operation
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
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.
|
|
82
91
|
*/
|
|
83
92
|
idempotencyKey?: string;
|
|
84
93
|
versionExpiredHandler?: VoidFunction;
|
|
@@ -129,6 +138,21 @@ export default class LambderCaller<TContract extends ApiContractShape = any> {
|
|
|
129
138
|
sessionCookieDomain?: string | ((hostname: string) => string | undefined | null);
|
|
130
139
|
});
|
|
131
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;
|
|
132
156
|
/**
|
|
133
157
|
* Generate an idempotency key for one logical operation. Create it when
|
|
134
158
|
* the operation begins (a form opens, a draft starts), send the same key
|
|
@@ -146,12 +170,6 @@ export default class LambderCaller<TContract extends ApiContractShape = any> {
|
|
|
146
170
|
* instead of collapsing every failure to null. Never throws.
|
|
147
171
|
*/
|
|
148
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>>;
|
|
149
|
-
/**
|
|
150
|
-
* Legacy shape: the parsed envelope on success (and on structured
|
|
151
|
-
* errorMessage refusals, which carry an envelope), null on every other
|
|
152
|
-
* failure. Prefer apiOutcome() when the call site needs to know why.
|
|
153
|
-
*/
|
|
154
|
-
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, ...rest: CallOptionsArg<TContract, TApiName>): Promise<LambderApiResponse<TOutput> | null | undefined>;
|
|
155
173
|
/** Payload on success, null/undefined otherwise (indistinguishable from a null payload; prefer apiOutcome() when that matters). */
|
|
156
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>;
|
|
157
175
|
}
|
|
@@ -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
|
|
@@ -285,18 +306,6 @@ export default class LambderCaller {
|
|
|
285
306
|
return await this.dispatch(apiName, payload, rest[0]);
|
|
286
307
|
}
|
|
287
308
|
;
|
|
288
|
-
/**
|
|
289
|
-
* Legacy shape: the parsed envelope on success (and on structured
|
|
290
|
-
* errorMessage refusals, which carry an envelope), null on every other
|
|
291
|
-
* failure. Prefer apiOutcome() when the call site needs to know why.
|
|
292
|
-
*/
|
|
293
|
-
async apiRaw(apiName, payload, ...rest) {
|
|
294
|
-
const outcome = await this.dispatch(apiName, payload, rest[0]);
|
|
295
|
-
if (outcome.ok)
|
|
296
|
-
return outcome.response;
|
|
297
|
-
return outcome.reason === 'errorMessage' ? outcome.response : null;
|
|
298
|
-
}
|
|
299
|
-
;
|
|
300
309
|
/** Payload on success, null/undefined otherwise (indistinguishable from a null payload; prefer apiOutcome() when that matters). */
|
|
301
310
|
async api(apiName, payload, ...rest) {
|
|
302
311
|
const outcome = await this.dispatch(apiName, payload, rest[0]);
|
|
@@ -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,17 +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 built with lambderGuard()
|
|
205
|
-
* apiInput
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
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.
|
|
213
215
|
*/
|
|
214
|
-
defineApiGuards<TGuards extends Record<string, LambderApiGuard<any>>>(guards: TGuards): Lambder<TSessionData, _TContract, _TRateLimitPolicies, _TGuards & LambderGuardMetaMap<TGuards>, _TIdempotencyEnabled>;
|
|
216
|
+
defineApiGuards<TGuards extends Record<string, LambderApiGuard<any, any, any>>>(guards: TGuards): Lambder<TSessionData, _TContract, _TRateLimitPolicies, _TGuards & LambderGuardMetaMap<TGuards>, _TIdempotencyEnabled>;
|
|
215
217
|
private getOrCreatePolicyEngine;
|
|
216
218
|
/** Registration-time checks shared by addApi/addSessionApi. */
|
|
217
219
|
private assertApiRegistration;
|
|
@@ -220,32 +222,32 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
220
222
|
addSessionRoute<TPath extends Path>(condition: TPath, actionFn: (ctx: LambderSessionRenderContext<any, TSessionData, PathParamsOf<TPath>>, resolver: LambderResolver) => MaybePromise<LambderResponse>): this;
|
|
221
223
|
addSessionRoute(condition: RegExp | ConditionFunction | LambderRouteMatcher, actionFn: SessionActionFunction<TSessionData>): this;
|
|
222
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>;
|
|
223
|
-
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
|
|
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: {
|
|
224
226
|
input: TInput;
|
|
225
227
|
output: TOutput;
|
|
226
228
|
} & {
|
|
227
229
|
/** Named rate limits, checked in declared order before guards and validation; the first exceeded one refuses (429 envelope). */
|
|
228
230
|
rateLimit?: TRateOpt;
|
|
229
|
-
/** 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. */
|
|
230
232
|
guards?: TGuardsOpt;
|
|
231
233
|
/** Replay-protect this API per client idempotencyKey. Requires enableApiIdempotency() first. */
|
|
232
234
|
idempotency?: _TIdempotencyEnabled extends true ? (boolean | {
|
|
233
235
|
ttlSeconds?: number;
|
|
234
236
|
}) : never;
|
|
235
|
-
}, handler: (ctx: LambderRenderContext<z.infer<TInput>>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>, LambderGuardInputsOf<_TGuards, TGuardsOpt>>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled>;
|
|
236
|
-
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
|
|
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: {
|
|
237
239
|
input: TInput;
|
|
238
240
|
output: TOutput;
|
|
239
241
|
} & {
|
|
240
242
|
/** Named rate limits, checked in declared order before guards and validation; the first exceeded one refuses (429 envelope). */
|
|
241
243
|
rateLimit?: TRateOpt;
|
|
242
|
-
/** 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. */
|
|
243
245
|
guards?: TGuardsOpt;
|
|
244
246
|
/** Replay-protect this API per client idempotencyKey. Requires enableApiIdempotency() first. */
|
|
245
247
|
idempotency?: _TIdempotencyEnabled extends true ? (boolean | {
|
|
246
248
|
ttlSeconds?: number;
|
|
247
249
|
}) : never;
|
|
248
|
-
}, handler: (ctx: LambderSessionRenderContext<z.infer<TInput>, TSessionData>, resolver: LambderResolver<z.infer<TOutput>>) => MaybePromise<LambderResponse>): Lambder<TSessionData, MergeContract<_TContract, TName, z.infer<TInput>, z.infer<TOutput>, LambderGuardInputsOf<_TGuards, TGuardsOpt>>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled>;
|
|
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>;
|
|
249
251
|
/**
|
|
250
252
|
* Fetch the session or short-circuit the request: API calls get the
|
|
251
253
|
* protocol's { sessionExpired: true } response (handled by LambderCaller),
|
|
@@ -290,3 +292,26 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
290
292
|
private resolveRequest;
|
|
291
293
|
render(event: LambderHttpEvent, lambdaContext: Context): Promise<LambderHttpResponse>;
|
|
292
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>;
|
|
@@ -3,11 +3,11 @@ import LambderResponseBuilder from "./LambderResponseBuilder.js";
|
|
|
3
3
|
import { LambderResponse, finalizeResponse, DEFAULT_FINALIZE_OPTIONS, } from "./LambderResponse.js";
|
|
4
4
|
import { compileRouteMatcher } from "./LambderRouting.js";
|
|
5
5
|
import { applyCorsHeaders } from "./LambderCors.js";
|
|
6
|
-
import LambderSessionManager from "
|
|
7
|
-
import LambderSessionController from "
|
|
6
|
+
import LambderSessionManager from "../session/LambderSessionManager.js";
|
|
7
|
+
import LambderSessionController from "../session/LambderSessionController.js";
|
|
8
8
|
import { LambderPublicFilesHandler } from "./LambderPublicFiles.js";
|
|
9
|
-
import { isLambderApiError } from "
|
|
10
|
-
import { LambderApiPolicyEngine
|
|
9
|
+
import { isLambderApiError } from "../shared/LambderApiError.js";
|
|
10
|
+
import { LambderApiPolicyEngine } from "../policies/LambderApiPolicies.js";
|
|
11
11
|
import { createContext, isV2HttpEvent } from "./LambderContext.js";
|
|
12
12
|
/**
|
|
13
13
|
* Main Lambder class for building type-safe serverless APIs
|
|
@@ -193,15 +193,15 @@ export default class Lambder {
|
|
|
193
193
|
}
|
|
194
194
|
/**
|
|
195
195
|
* Define named guards that APIs reference (typed) via the `guards`
|
|
196
|
-
* option. Each guard is built with lambderGuard()
|
|
197
|
-
* apiInput
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
196
|
+
* option. Each guard is built with lambderGuard(): its input mode
|
|
197
|
+
* (apiInput slice of the API's own payload, a separate client-sent
|
|
198
|
+
* guardInput, or none), an optional `session: true` requirement, an
|
|
199
|
+
* optional parameter APIs pass in their declaration (`guards: { name:
|
|
200
|
+
* param }`), and an optional return value that lands typed on the
|
|
201
|
+
* handler's ctx.guardData[name]. Guards run before input validation, in
|
|
202
|
+
* the order the API declares them; a handler refuses by throwing
|
|
203
|
+
* (typically refuse()). Callable multiple times so domain modules can
|
|
204
|
+
* contribute their own; names must not collide.
|
|
205
205
|
*/
|
|
206
206
|
defineApiGuards(guards) {
|
|
207
207
|
this.getOrCreatePolicyEngine().addGuards(guards);
|
|
@@ -257,6 +257,14 @@ export default class Lambder {
|
|
|
257
257
|
this.actionList.push({
|
|
258
258
|
match: (ctx) => ctx.apiName === name ? {} : false,
|
|
259
259
|
actionFn: async (ctx, resolver) => {
|
|
260
|
+
// Replay fast path first: a completed idempotent request must
|
|
261
|
+
// answer its stored response without burning rate-limit quota
|
|
262
|
+
// or re-running guards (no handler executes either way).
|
|
263
|
+
if (this.apiPolicyEngine && schema.idempotency) {
|
|
264
|
+
const replay = await this.apiPolicyEngine.findReplay(name, ctx);
|
|
265
|
+
if (replay)
|
|
266
|
+
return replay;
|
|
267
|
+
}
|
|
260
268
|
if (this.apiPolicyEngine)
|
|
261
269
|
await this.apiPolicyEngine.runPreflight(name, ctx, resolver, schema);
|
|
262
270
|
const inputResult = schema.input.safeParse(ctx.apiPayload);
|
|
@@ -282,6 +290,13 @@ export default class Lambder {
|
|
|
282
290
|
match: (ctx) => ctx.apiName === name ? {} : false,
|
|
283
291
|
actionFn: async (ctx, resolver) => {
|
|
284
292
|
await this.requireSession(ctx, resolver);
|
|
293
|
+
// Replay fast path (after the session fetch: the replay scope
|
|
294
|
+
// is keyed per session): see addApi.
|
|
295
|
+
if (this.apiPolicyEngine && schema.idempotency) {
|
|
296
|
+
const replay = await this.apiPolicyEngine.findReplay(name, ctx);
|
|
297
|
+
if (replay)
|
|
298
|
+
return replay;
|
|
299
|
+
}
|
|
285
300
|
if (this.apiPolicyEngine)
|
|
286
301
|
await this.apiPolicyEngine.runPreflight(name, ctx, resolver, schema);
|
|
287
302
|
const inputResult = schema.input.safeParse(ctx.apiPayload);
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { APIGatewayProxyEvent, APIGatewayProxyEventV2, APIGatewayProxyEventHeaders, Context } from "aws-lambda";
|
|
2
|
-
import type { LambderSessionContext } from "
|
|
2
|
+
import type { LambderSessionContext } from "../session/LambderSessionManager.js";
|
|
3
3
|
import type { LambderHttpEventFormat } from "./LambderResponse.js";
|
|
4
4
|
export type LambderHttpEvent = APIGatewayProxyEvent | APIGatewayProxyEventV2;
|
|
5
5
|
/** True for API Gateway HTTP API / Lambda Function URL (payload v2) events. */
|
|
6
6
|
export declare const isV2HttpEvent: (event: unknown) => event is APIGatewayProxyEventV2;
|
|
7
|
-
export type LambderRenderContext<TApiPayload = any, TPathParams extends Record<string, string> = Record<string, string
|
|
7
|
+
export type LambderRenderContext<TApiPayload = any, TPathParams extends Record<string, string> = Record<string, string>, TGuardData = {}> = {
|
|
8
8
|
host: string;
|
|
9
9
|
path: string;
|
|
10
10
|
pathParams: TPathParams;
|
|
@@ -15,6 +15,12 @@ export type LambderRenderContext<TApiPayload = any, TPathParams extends Record<s
|
|
|
15
15
|
session: null;
|
|
16
16
|
apiName: string | null;
|
|
17
17
|
apiPayload: TApiPayload;
|
|
18
|
+
/**
|
|
19
|
+
* Outputs of this API's guards, keyed by guard name. Only guards the API
|
|
20
|
+
* declares AND that return a value appear (typed via the declarative
|
|
21
|
+
* guards option); void guards never do.
|
|
22
|
+
*/
|
|
23
|
+
guardData: TGuardData;
|
|
18
24
|
headers: APIGatewayProxyEventHeaders;
|
|
19
25
|
/** Decoded request body, exactly as received (e.g. for webhook signature verification). */
|
|
20
26
|
rawBody: string;
|
|
@@ -39,7 +45,7 @@ export type LambderRenderContext<TApiPayload = any, TPathParams extends Record<s
|
|
|
39
45
|
logToApiResponseAccumulator: any[];
|
|
40
46
|
};
|
|
41
47
|
};
|
|
42
|
-
export type LambderSessionRenderContext<TApiPayload = any, SessionData = any, TPathParams extends Record<string, string> = Record<string, string
|
|
48
|
+
export type LambderSessionRenderContext<TApiPayload = any, SessionData = any, TPathParams extends Record<string, string> = Record<string, string>, TGuardData = {}> = Omit<LambderRenderContext<TApiPayload, TPathParams, TGuardData>, 'session'> & {
|
|
43
49
|
session: LambderSessionContext<SessionData>;
|
|
44
50
|
};
|
|
45
51
|
export declare const createContext: (event: LambderHttpEvent, lambdaContext: Context, apiPath: string) => LambderRenderContext;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import mimeTypeResolver from "mime-types";
|
|
2
|
-
import { getFS, getPath } from "
|
|
2
|
+
import { getFS, getPath } from "../shared/node-polyfills.js";
|
|
3
3
|
import { LambderResponse } from "./LambderResponse.js";
|
|
4
4
|
// Content-hashed build outputs (Vite/webpack/Rollup): a [-.] separated run of
|
|
5
5
|
// 8+ hash chars containing at least one digit, before the extension.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getZlib, getCrypto } from "
|
|
1
|
+
import { getZlib, getCrypto } from "../shared/node-polyfills.js";
|
|
2
2
|
export const normalizeHeaders = (headers) => Object.fromEntries(Object.entries(headers ?? {}).map(([k, v]) => [k, Array.isArray(v) ? [...v] : [v]]));
|
|
3
3
|
/**
|
|
4
4
|
* Intermediate response object returned by all response builder methods and by
|