lambder 4.6.1 → 4.7.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 +88 -3
- package/dist/client/LambderCaller.d.ts +19 -0
- package/dist/client/LambderCaller.js +19 -2
- package/dist/client/LambderMSW.js +15 -0
- package/dist/client.d.ts +4 -0
- package/dist/client.js +4 -0
- package/dist/core/Lambder.d.ts +50 -14
- package/dist/core/Lambder.js +36 -6
- package/dist/core/LambderContext.d.ts +20 -0
- package/dist/core/LambderContext.js +54 -0
- package/dist/core/LambderResponse.d.ts +21 -3
- package/dist/core/LambderResponse.js +26 -9
- package/dist/index.d.ts +7 -2
- package/dist/index.js +7 -0
- package/dist/session/LambderSessionManager.d.ts +1 -1
- package/dist/session/LambderSessionManager.js +4 -3
- package/dist/shared/LambderApiError.d.ts +2 -0
- package/dist/shared/LambderApiError.js +2 -0
- package/dist/shared/LambderCompressionCodec.d.ts +55 -0
- package/dist/shared/LambderCompressionCodec.js +113 -0
- package/dist/shared/LambderCompressionOption.d.ts +51 -0
- package/dist/shared/LambderCompressionOption.js +52 -0
- package/dist/shared/LambderRequestPayload.d.ts +80 -0
- package/dist/shared/LambderRequestPayload.js +96 -0
- package/dist/stores/LambderDdbCache.d.ts +1 -1
- package/dist/stores/LambderDdbCache.js +5 -4
- package/dist/stores/LambderDdbIdempotency.d.ts +1 -1
- package/dist/stores/LambderDdbIdempotency.js +4 -3
- package/package.json +22 -7
- package/dist/stores/LambderDdbCompression.d.ts +0 -25
- package/dist/stores/LambderDdbCompression.js +0 -61
package/Readme.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
Lambder is a highly opinionated dynamic serverless framework designed to facilitate the management and implementation of routes and APIs within AWS Lambda functions, specifically tailored for TypeScript projects. It provides a streamlined approach to handling HTTP requests, managing sessions, and defining API routes, making serverless application development more intuitive and structured.
|
|
4
4
|
|
|
5
|
+
**New in 4.7:**
|
|
6
|
+
|
|
7
|
+
- **Compressed request payloads**: `requestCompression` on `LambderCaller` gzips the payload of any call whose JSON reaches a threshold (`true` is `{ minBytes: 4096 }`), sending it as `payloadGz` beside its byte length instead of `payload` whenever that is actually smaller; the server restores it before rate-limit key slices, guards and input validation, so no call site, handler or schema changes. Chiefly a way to fit a large payload under Lambda's ~6MB invoke cap, which applies to the compressed bytes. The envelope stays `application/json` with its routing fields in plain text, so gateways, CDNs and mocks are unaffected. `maxRequestPayloadBytes` (default 20MB) bounds what a body may expand to.
|
|
8
|
+
- **One compression codec**: `shared/LambderCompressionCodec.ts` is now the only place Lambder compresses or decompresses bytes. Its `restoreBoundedText(bytes, declaredBytes, encoding)` carries the guarantee every compressed value in Lambder depends on, at rest and on the wire: the declared UTF-8 byte length bounds the decompression AND must match the result exactly, so a truncated, tampered or endlessly-expanding input fails instead of decoding to something merely plausible. Compression is split across three modules by what each one needs: the codec (zlib), the option and its resolver (pure, so the browser entry can resolve the caller's option), and the request payload format (the browser's CompressionStream). `stores/LambderDdbCompression.ts` is retired into them.
|
|
9
|
+
- **One compression option, now everywhere**: the HTTP response option and the new request option resolve through the same `resolveCompressionOption` the DynamoDB stores and sessions use, and every site's option is the one generic `LambderCompressionOption<Settings>`. Same vocabulary at every site (`true` for that site's defaults, `false` for off, an object to override, `minBytes` as the threshold, `quality` as the Brotli quality, `encodings` as the negotiation order), same `Settings | null` resolved shape, and the same startup validation: `compression: { quality: 99 }` or `{ encodings: [] }` on a response is now a construction error instead of being silently ignored, and a field set to `undefined` keeps its default.
|
|
10
|
+
- **Brotli responses**: response compression now negotiates `br` before `gzip`, smaller at comparable speed (15-25% on markup and prose, substantially more on the repetitive record lists API responses tend to be), which is bandwidth saved and headroom gained against the ~6MB response cap. `compression: { encodings: ["gzip"] }` opts out, `quality` (default 5) tunes it.
|
|
11
|
+
- **Mandatory authorization on session APIs**: `requireSessionApiGuards: true` at creation makes `guards` a required field of every `addSessionApi`, at the type level (a missing declaration is a compile error at the registration site) and at registration (a plain-JS caller throws). An API the session alone authorizes declares a named no-op session guard, so every opt-out is explicit and one grep lists them all. The class of defect this closes is "the guard existed and the endpoint did not use it", which review discipline does not catch as a surface grows.
|
|
12
|
+
|
|
5
13
|
**New in 4.6:**
|
|
6
14
|
|
|
7
15
|
- **Cookies as a first-class concern**: `res.setCookie(name, value, options)` and `res.clearCookie(name, options)` serialize Set-Cookie headers through the `cookie` package (defaults Path=/, SameSite=Lax, Secure; a function-form `domain` resolves against the request hostname, the same option the session takes), replacing hand-built header strings; `serializeCookie`/`serializeClearCookie` are exported for code holding a response. `ctx.cookieList` keeps every value a cookie name arrived with beside the first-wins `ctx.cookie`.
|
|
@@ -74,6 +82,24 @@ npm install lambder zod
|
|
|
74
82
|
yarn add lambder zod
|
|
75
83
|
```
|
|
76
84
|
|
|
85
|
+
`zod` and the AWS SDK clients are optional peer dependencies, so installing
|
|
86
|
+
lambder never drags them into your tree. Add whatever the code you actually
|
|
87
|
+
import needs:
|
|
88
|
+
|
|
89
|
+
| What you import | What to install alongside |
|
|
90
|
+
|---|---|
|
|
91
|
+
| `lambder/client` (browser, shared isomorphic code) | `zod` |
|
|
92
|
+
| `lambder` on AWS Lambda (`nodejs18.x` and later) | `zod`. The runtime already provides the AWS SDK v3, so mark the SDK packages as dev dependencies and keep them out of the deployment package |
|
|
93
|
+
| `lambder` anywhere else (a long-running server, a container, local tests) | `zod`, `@aws-sdk/client-dynamodb`, `@aws-sdk/lib-dynamodb` |
|
|
94
|
+
| `LambderS3FileSource` | `@aws-sdk/client-s3`, loaded on first read |
|
|
95
|
+
| `lambder/testing` | `msw` |
|
|
96
|
+
|
|
97
|
+
The SDK and its `@smithy` tree are roughly 21MB installed, which is why they are
|
|
98
|
+
peers rather than dependencies: a frontend importing only `lambder/client` has
|
|
99
|
+
no use for any of it, and a Lambda deployment package should not ship a second
|
|
100
|
+
copy of what the runtime already loads. The runtime pins its own SDK version,
|
|
101
|
+
so if you need a specific one, install it and bundle it yourself.
|
|
102
|
+
|
|
77
103
|
## Package Entry Points
|
|
78
104
|
|
|
79
105
|
The package ships three entry points; pick by where the code runs:
|
|
@@ -385,7 +411,7 @@ Semantics:
|
|
|
385
411
|
|
|
386
412
|
#### Session data at rest (`compression`)
|
|
387
413
|
|
|
388
|
-
`session.data` is stored Brotli-compressed by default: the record carries the data's JSON as Brotli bytes in `dataBr` beside its byte length in `dataBytes`, in place of a plain `data` attribute. It is the scheme `LambderDdbCache` and `LambderDdbIdempotency` already use, from one shared
|
|
414
|
+
`session.data` is stored Brotli-compressed by default: the record carries the data's JSON as Brotli bytes in `dataBr` beside its byte length in `dataBytes`, in place of a plain `data` attribute. It is the scheme `LambderDdbCache` and `LambderDdbIdempotency` already use, from the one shared codec that also restores compressed request payloads, and the byte length both bounds the decompression and verifies it, so a truncated record fails to decode rather than decoding to something else. Session data that caches roles, permissions or product lists typically shrinks 2-3x, which keeps a growing session within one DynamoDB read unit (4KB for the consistent reads sessions use) and one write unit (1KB) for longer.
|
|
389
415
|
|
|
390
416
|
```typescript
|
|
391
417
|
session: {
|
|
@@ -549,9 +575,18 @@ The `ctx` object provides access to request data:
|
|
|
549
575
|
| `await res.file(path, options? & { fallback? })` | Serve file from public directory (404 when missing) |
|
|
550
576
|
| `await res.templateFile(path, data?, options?)` | Render an HTML file via LambderTemplatingEngine (cached; throws when missing) |
|
|
551
577
|
| `res.api(payload, config?, options?)` | Standardized API response |
|
|
552
|
-
| `res.apiBinary(payload, config?, options?)` | API response with forced
|
|
578
|
+
| `res.apiBinary(payload, config?, options?)` | API response with forced compression |
|
|
553
579
|
|
|
554
|
-
Responses are finalized once at the end of the request: automatic
|
|
580
|
+
Responses are finalized once at the end of the request: automatic compression (when the client accepts it, the body is compressible and large enough), automatic ETag + `If-None-Match` 304 handling on GET/HEAD, and a clear error if the body would exceed Lambda's ~6MB cap. Override per response with `compress: true | false` and `etag: false`.
|
|
581
|
+
|
|
582
|
+
The encoding is negotiated against `Accept-Encoding` in the order `compression.encodings` declares, `["br", "gzip"]` by default. Brotli at quality 5 (`compression.quality`) runs at roughly gzip's speed while producing smaller bodies: 15-25% on markup and prose, and substantially more on the repetitive record lists API responses tend to be. Because the ~6MB cap is checked on the FINAL body, that is headroom as well as bandwidth. A client that offers only gzip gets gzip, and `compression: { encodings: ["gzip"] }` turns Brotli off entirely for a CDN or client that mishandles it. `Vary: Accept-Encoding` rides every compressible response, whether or not this particular client accepted an encoding, so shared caches stay correct.
|
|
583
|
+
|
|
584
|
+
```typescript
|
|
585
|
+
initLambder().create({
|
|
586
|
+
compression: { minBytes: 860, encodings: ["br", "gzip"], quality: 5 }, // the defaults
|
|
587
|
+
// compression: false, // no automatic compression at all
|
|
588
|
+
});
|
|
589
|
+
```
|
|
555
590
|
|
|
556
591
|
**API Config Options**: `{ notAuthorized, message, errorMessage, versionExpired, sessionExpired, logList }`
|
|
557
592
|
|
|
@@ -697,6 +732,24 @@ lambder.addSessionApi("secure.order.create", {
|
|
|
697
732
|
|
|
698
733
|
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.
|
|
699
734
|
|
|
735
|
+
**Requiring an authorization declaration (`requireSessionApiGuards`)**: by default a session API may declare no guards, which reads as "any signed-in user". Once an app has an authorization vocabulary, that silence is where defects hide: the guard exists, a new endpoint forgets it, and nothing notices. With `requireSessionApiGuards: true` at creation, `guards` becomes a required field of every `addSessionApi`: omitting it is a compile error at the registration site ("Property 'guards' is missing"), and a plain-JS registration throws. Public APIs are unaffected. An API that legitimately needs no authorization beyond the session (the signed-in user's own account, a log-out) declares a named no-op session guard, so the opt-out is explicit, greppable, and cannot be used on a public API:
|
|
736
|
+
|
|
737
|
+
```typescript
|
|
738
|
+
const lambder = initLambder<SessionData>().create({
|
|
739
|
+
apiPath: "/api",
|
|
740
|
+
guards: {
|
|
741
|
+
orgPermission: lambderGuard({ session: true, handler: (ctx, _p, _r, permission: PermissionString) => requireOrRefuse(ctx.session, permission) }),
|
|
742
|
+
// The one opt-out: the session itself is the whole authorization.
|
|
743
|
+
sessionOnly: lambderGuard({ session: true, handler: () => {} }),
|
|
744
|
+
},
|
|
745
|
+
requireSessionApiGuards: true,
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
lambder.addSessionApi("secure.order.create", { input, output, guards: { orgPermission: "ORDERS.CREATE" } }, handler);
|
|
749
|
+
lambder.addSessionApi("secure.me.logOut", { input, output, guards: "sessionOnly" }, handler);
|
|
750
|
+
lambder.addSessionApi("secure.report.list", { input, output }, handler); // compile error: which guard?
|
|
751
|
+
```
|
|
752
|
+
|
|
700
753
|
For api modules split across files, DERIVE the annotation type from the real instance instead of writing it by hand: create the instance next to the policy declarations and export `typeof` it. The type can never drift from what actually runs, and modules import it without a cycle (the app file imports no modules):
|
|
701
754
|
|
|
702
755
|
```typescript
|
|
@@ -805,6 +858,38 @@ const user = await lambderCaller.api("getCompanyPage", { companyName: "Acme" });
|
|
|
805
858
|
// - Expected output type
|
|
806
859
|
```
|
|
807
860
|
|
|
861
|
+
### Compressed Request Payloads
|
|
862
|
+
|
|
863
|
+
Large payloads run into Lambda's ~6MB invoke payload cap long before the API Gateway limit, and the cap applies to what the gateway hands the function. `requestCompression` gzips the payload of any call whose JSON reaches the threshold, so that budget holds the compressed bytes instead of the raw ones:
|
|
864
|
+
|
|
865
|
+
```typescript
|
|
866
|
+
const lambderCaller = new LambderCaller<ApiContractType>({
|
|
867
|
+
apiPath: "/api",
|
|
868
|
+
isCorsEnabled: false,
|
|
869
|
+
requestCompression: true, // { minBytes: 4096 }
|
|
870
|
+
// requestCompression: { minBytes: 64_000 }, // only genuinely large calls
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
// Nothing at the call sites changes; this one goes compressed, that one plain.
|
|
874
|
+
await lambderCaller.api("importStops", { stops: bigArray });
|
|
875
|
+
await lambderCaller.api("getStop", { id: "42" });
|
|
876
|
+
|
|
877
|
+
// Per call, either way:
|
|
878
|
+
await lambderCaller.api("importStops", huge, { compressRequest: false });
|
|
879
|
+
```
|
|
880
|
+
|
|
881
|
+
A compressed call sends `payloadGz` (gzip bytes, base64) beside `payloadBytes` (the JSON's UTF-8 byte length) in place of `payload`. It is only sent when it is smaller than the JSON it replaces: a payload that is mostly a base64 image gzips to nearly its own size, and such a call goes plain rather than slightly larger. Everything else in the envelope stays plain text, so `apiName` routing, request logs and MSW mocks are unaffected, and the request stays `application/json`: no `Content-Encoding` negotiation for a gateway, CDN or proxy to get wrong, and no new CORS preflight surface. Base64 inside the JSON rather than a binary body is not a compromise for the size cap, because API Gateway hands a binary request body to Lambda base64-encoded anyway; base64's 4/3 overhead applies to bytes that already shrank several times over. Record-shaped JSON typically gzips 5-10x, so a ~5MB budget of compressed payload carries roughly 25-40MB of it.
|
|
882
|
+
|
|
883
|
+
The option is off by default and safe to turn on or off at any time: the server understands both shapes regardless, so a deployed client and server never need to agree. gzip rather than Brotli because the browser's `CompressionStream` offers gzip and deflate only; responses, compressed by Node, do prefer Brotli. A runtime without `CompressionStream` sends payloads plainly.
|
|
884
|
+
|
|
885
|
+
**Server side**: nothing to enable. The payload is restored before rate-limit key slices, guards and input validation run, so handlers, schemas and policies see an ordinary payload and need no awareness of the wire format. `payloadBytes` both bounds the decompression and verifies it (the restored length must match exactly), so a truncated or hostile body is refused rather than expanded, and `maxRequestPayloadBytes` at creation (default 20,000,000) caps what any body may expand to. Size that ceiling to the function's memory: the restored JSON is parsed in full before any session or policy check, and a parsed document occupies several times its text size on the heap. Every malformed case answers a 400 envelope coded `lambder/invalid-request-payload` instead of a 500.
|
|
886
|
+
|
|
887
|
+
```typescript
|
|
888
|
+
initLambder().create({ apiPath: "/api", maxRequestPayloadBytes: 20_000_000 });
|
|
889
|
+
```
|
|
890
|
+
|
|
891
|
+
Compression moves the ceiling rather than removing it. Past roughly 25-40MB of JSON the answer is a presigned S3 upload plus a job reference, or chunking, not a better codec.
|
|
892
|
+
|
|
808
893
|
### Failure Semantics (apiOutcome, timeouts, per-call overrides)
|
|
809
894
|
|
|
810
895
|
`api()` collapses every failure to `null`, which is indistinguishable from a legitimately-null payload. When the call site needs to know why, use `apiOutcome()`; it never throws and resolves to a discriminated union:
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type LambderRequestCompressionOption } from '../shared/LambderRequestPayload.js';
|
|
1
2
|
import type { LambderApiResponse } from '../shared/LambderApiContract.js';
|
|
2
3
|
import type { ApiContractShape } from '../shared/LambderApiContract.js';
|
|
3
4
|
import type { z } from "zod";
|
|
@@ -114,6 +115,13 @@ export type LambderCallOptions = {
|
|
|
114
115
|
timeoutMs?: number;
|
|
115
116
|
/** External abort signal, combined with the timeout when both are set. */
|
|
116
117
|
signal?: AbortSignal;
|
|
118
|
+
/**
|
|
119
|
+
* Overrides the constructor's requestCompression for this call: `false`
|
|
120
|
+
* sends the payload plainly (a hot path where the CPU matters more than
|
|
121
|
+
* the bytes), `true` compresses it regardless of the size threshold.
|
|
122
|
+
* Either way a payload is only sent compressed when that is smaller.
|
|
123
|
+
*/
|
|
124
|
+
compressRequest?: boolean;
|
|
117
125
|
/**
|
|
118
126
|
* Values for the API's guardInput-mode guards, keyed by guard name; sent
|
|
119
127
|
* beside the payload and consumed by the guards before validation. The
|
|
@@ -159,6 +167,16 @@ type LambderCallerBaseOptions = {
|
|
|
159
167
|
apiInputValidationErrorHandler?: ValidationErrorHandler;
|
|
160
168
|
/** Must mirror the server's session cookie Domain, otherwise expired cookies cannot be cleared. */
|
|
161
169
|
sessionCookieDomain?: string | ((hostname: string) => string | undefined | null);
|
|
170
|
+
/**
|
|
171
|
+
* Gzip the payload of calls whose JSON reaches the threshold, sending it
|
|
172
|
+
* as `payloadGz` beside its byte length instead of `payload` whenever
|
|
173
|
+
* that is smaller (a base64 image, say, is not, and goes plain). Off by
|
|
174
|
+
* default; `true` is `{ minBytes: 4096 }`. Nothing at the call sites
|
|
175
|
+
* changes, and the server understands both shapes either way, so it can
|
|
176
|
+
* be turned on or off freely. Chiefly a way to fit a large payload under
|
|
177
|
+
* Lambda's ~6MB invoke cap, which applies to the compressed bytes.
|
|
178
|
+
*/
|
|
179
|
+
requestCompression?: LambderRequestCompressionOption;
|
|
162
180
|
};
|
|
163
181
|
/** Constructor options: the base options plus guardInputsProvider, mandatory once TProvided names guards. */
|
|
164
182
|
export type LambderCallerOptions<TContract, TProvided extends string = never> = LambderCallerBaseOptions & GuardInputsProviderOption<TContract, TProvided>;
|
|
@@ -186,6 +204,7 @@ export default class LambderCaller<TContract extends ApiContractShape = any, TPr
|
|
|
186
204
|
private sessionTokenCookieKey;
|
|
187
205
|
private sessionCsrfCookieKey;
|
|
188
206
|
private sessionCookieDomain?;
|
|
207
|
+
private requestCompression;
|
|
189
208
|
constructor(options: LambderCallerOptions<TContract, TProvidedGuards>);
|
|
190
209
|
setSessionCookieKey(sessionTokenCookieKey: string, sessionCsrfCookieKey: string): void;
|
|
191
210
|
/**
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import Cookies from 'js-cookie';
|
|
2
|
+
import { compressPayloadJson, isRequestCompressionAvailable, DEFAULT_REQUEST_COMPRESSION_SETTINGS, } from '../shared/LambderRequestPayload.js';
|
|
3
|
+
import { resolveCompressionOption } from '../shared/LambderCompressionOption.js';
|
|
2
4
|
/**
|
|
3
5
|
* @typeParam TContract - The API contract, for typed names, payloads and guard inputs.
|
|
4
6
|
* @typeParam TProvidedGuards - Guard names guardInputsProvider covers; those APIs' options argument becomes optional.
|
|
@@ -23,15 +25,18 @@ export default class LambderCaller {
|
|
|
23
25
|
sessionTokenCookieKey = "LMDRSESSIONTKID";
|
|
24
26
|
sessionCsrfCookieKey = "LMDRSESSIONCSTK";
|
|
25
27
|
sessionCookieDomain;
|
|
28
|
+
requestCompression;
|
|
26
29
|
constructor(options) {
|
|
27
30
|
// The conditional provider option is resolved per instantiation;
|
|
28
31
|
// inside the class it is read through the plain shape.
|
|
29
|
-
const { apiPath, apiVersion, isCorsEnabled = false, timeoutMs, versionExpiredHandler, sessionExpiredHandler, messageHandler, errorMessageHandler, notAuthorizedHandler, errorHandler, fetchStartedHandler, fetchEndedHandler, apiInputValidationErrorHandler, sessionCookieDomain, guardInputsProvider, } = options;
|
|
32
|
+
const { apiPath, apiVersion, isCorsEnabled = false, timeoutMs, versionExpiredHandler, sessionExpiredHandler, messageHandler, errorMessageHandler, notAuthorizedHandler, errorHandler, fetchStartedHandler, fetchEndedHandler, apiInputValidationErrorHandler, sessionCookieDomain, requestCompression, guardInputsProvider, } = options;
|
|
30
33
|
this.apiPath = apiPath ?? "/api";
|
|
31
34
|
this.apiVersion = apiVersion;
|
|
32
35
|
this.isCorsEnabled = isCorsEnabled;
|
|
33
36
|
this.timeoutMs = timeoutMs;
|
|
34
37
|
this.sessionCookieDomain = sessionCookieDomain;
|
|
38
|
+
// `?? false`: unlike the at-rest stores, this one is off unless asked for.
|
|
39
|
+
this.requestCompression = resolveCompressionOption(requestCompression ?? false, DEFAULT_REQUEST_COMPRESSION_SETTINGS);
|
|
35
40
|
this.versionExpiredHandler = versionExpiredHandler;
|
|
36
41
|
this.sessionExpiredHandler = sessionExpiredHandler;
|
|
37
42
|
this.messageHandler = messageHandler;
|
|
@@ -178,6 +183,17 @@ export default class LambderCaller {
|
|
|
178
183
|
const guardInputs = providedGuardInputs !== undefined || options?.guardInputs !== undefined
|
|
179
184
|
? { ...providedGuardInputs, ...options?.guardInputs }
|
|
180
185
|
: undefined;
|
|
186
|
+
// Compressed when enabled and the payload's JSON reaches the
|
|
187
|
+
// threshold; `compressRequest` overrides both ways, and a runtime
|
|
188
|
+
// without CompressionStream always sends the payload plainly.
|
|
189
|
+
// Nothing here runs (the extra stringify included) unless
|
|
190
|
+
// compression is actually a possibility for this call.
|
|
191
|
+
const compressionMinBytes = options?.compressRequest === true ? 0
|
|
192
|
+
: options?.compressRequest === false ? null
|
|
193
|
+
: this.requestCompression?.minBytes ?? null;
|
|
194
|
+
const compressedPayload = compressionMinBytes !== null && payload !== undefined && isRequestCompressionAvailable()
|
|
195
|
+
? await compressPayloadJson(JSON.stringify(payload), compressionMinBytes)
|
|
196
|
+
: null;
|
|
181
197
|
let res;
|
|
182
198
|
try {
|
|
183
199
|
res = await fetch(this.apiPath, {
|
|
@@ -188,7 +204,8 @@ export default class LambderCaller {
|
|
|
188
204
|
redirect: 'follow', referrerPolicy: 'origin',
|
|
189
205
|
headers: { 'Content-Type': 'application/json', ...(headers || {}) },
|
|
190
206
|
body: JSON.stringify({
|
|
191
|
-
apiName, version, token, siteHost,
|
|
207
|
+
apiName, version, token, siteHost,
|
|
208
|
+
...(compressedPayload ?? { payload }),
|
|
192
209
|
...(guardInputs !== undefined ? { guardInputs } : {}),
|
|
193
210
|
...(options?.idempotencyKey !== undefined ? { idempotencyKey: options.idempotencyKey } : {}),
|
|
194
211
|
}),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { COMPRESSED_PAYLOAD_FIELD, COMPRESSED_PAYLOAD_BYTES_FIELD, decompressPayloadJson } from '../shared/LambderRequestPayload.js';
|
|
1
2
|
export default class LambderMSW {
|
|
2
3
|
apiPath;
|
|
3
4
|
apiVersion;
|
|
@@ -42,6 +43,20 @@ export default class LambderMSW {
|
|
|
42
43
|
if (body.apiName !== apiName) {
|
|
43
44
|
return;
|
|
44
45
|
}
|
|
46
|
+
// A caller with requestCompression on sends the payload gzipped;
|
|
47
|
+
// mock handlers still receive the payload itself, and the wire
|
|
48
|
+
// fields are consumed the way the server consumes them.
|
|
49
|
+
if (typeof body[COMPRESSED_PAYLOAD_FIELD] === 'string') {
|
|
50
|
+
try {
|
|
51
|
+
body.payload = await decompressPayloadJson(body[COMPRESSED_PAYLOAD_FIELD]);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
console.warn("LambderMSW: Failed to decompress the request payload");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
delete body[COMPRESSED_PAYLOAD_FIELD];
|
|
58
|
+
delete body[COMPRESSED_PAYLOAD_BYTES_FIELD];
|
|
59
|
+
}
|
|
45
60
|
try {
|
|
46
61
|
// Add artificial delay if specified
|
|
47
62
|
if (options?.delay) {
|
package/dist/client.d.ts
CHANGED
|
@@ -11,6 +11,10 @@ export type { LambderApiOutcome, LambderApiFailureReason, LambderCallOptions, La
|
|
|
11
11
|
export { LambderApiError, isLambderApiError, refuse, LAMBDER_REFUSAL_CODES } from "./shared/LambderApiError.js";
|
|
12
12
|
export type { LambderApiErrorOptions, LambderRefusalMessage, LambderRefusalCode, LambderRefuseOptions } from "./shared/LambderApiError.js";
|
|
13
13
|
export type { ApiContractShape, LambderApiResponse, LambderApiResponseConfig } from "./shared/LambderApiContract.js";
|
|
14
|
+
export { compressPayloadJson, decompressPayloadJson, isRequestCompressionAvailable, COMPRESSED_PAYLOAD_FIELD, COMPRESSED_PAYLOAD_BYTES_FIELD, DEFAULT_REQUEST_COMPRESSION_SETTINGS, } from "./shared/LambderRequestPayload.js";
|
|
15
|
+
export type { LambderCompressedPayload, LambderRequestCompressionOption, LambderRequestCompressionSettings, } from "./shared/LambderRequestPayload.js";
|
|
16
|
+
export { resolveCompressionOption } from "./shared/LambderCompressionOption.js";
|
|
17
|
+
export type { LambderCompressionOption, LambderCompressionSettingsBase } from "./shared/LambderCompressionOption.js";
|
|
14
18
|
export { html, xml, raw, jsonScript, escapeHtml, renderHtmlValue, LambderSafeHtml, type LambderHtmlValue } from "./shared/LambderHtml.js";
|
|
15
19
|
export { createLambderI18n } from "./shared/LambderI18n.js";
|
|
16
20
|
export type { LambderLanguageMeta, LambderI18nConfig, LambderI18nInstance, LambderI18nTranslator, LambderI18nExtractParams, LambderI18nCodes, LambderI18nKeys, LambderI18nTranslatorFor, } from "./shared/LambderI18n.js";
|
package/dist/client.js
CHANGED
|
@@ -11,6 +11,10 @@ export { default as LambderCaller } from "./client/LambderCaller.js";
|
|
|
11
11
|
// Typed API refusals (isomorphic: shared code may throw them from anywhere;
|
|
12
12
|
// in the browser they are plain Errors).
|
|
13
13
|
export { LambderApiError, isLambderApiError, refuse, LAMBDER_REFUSAL_CODES } from "./shared/LambderApiError.js";
|
|
14
|
+
// Request payload compression (browser-safe: gzip via CompressionStream, no Node built-ins).
|
|
15
|
+
export { compressPayloadJson, decompressPayloadJson, isRequestCompressionAvailable, COMPRESSED_PAYLOAD_FIELD, COMPRESSED_PAYLOAD_BYTES_FIELD, DEFAULT_REQUEST_COMPRESSION_SETTINGS, } from "./shared/LambderRequestPayload.js";
|
|
16
|
+
// The compression option vocabulary every Lambder surface shares (pure: no zlib).
|
|
17
|
+
export { resolveCompressionOption } from "./shared/LambderCompressionOption.js";
|
|
14
18
|
// Type-safe templating (tagged templates with auto-escaping)
|
|
15
19
|
export { html, xml, raw, jsonScript, escapeHtml, renderHtmlValue, LambderSafeHtml } from "./shared/LambderHtml.js";
|
|
16
20
|
// Typed translations (standalone, isomorphic)
|
package/dist/core/Lambder.d.ts
CHANGED
|
@@ -2,11 +2,11 @@ import type { z } from "zod";
|
|
|
2
2
|
import type { Context } from "aws-lambda";
|
|
3
3
|
import LambderResolver from "./LambderResolver.js";
|
|
4
4
|
import LambderResponseBuilder from "./LambderResponseBuilder.js";
|
|
5
|
-
import { LambderResponse, type LambderHttpResponse } from "./LambderResponse.js";
|
|
5
|
+
import { LambderResponse, type LambderHttpResponse, type LambderResponseCompressionOption } from "./LambderResponse.js";
|
|
6
6
|
import { type ConditionFunction, type LambderRouteMatcher, type PathParamsOf } from "./LambderRouting.js";
|
|
7
7
|
import { type LambderCorsConfig } from "./LambderCors.js";
|
|
8
8
|
import { type LambderSessionDataRefreshConfig } from "../session/LambderSessionManager.js";
|
|
9
|
-
import type
|
|
9
|
+
import { type LambderCompressionOption } from "../shared/LambderCompressionOption.js";
|
|
10
10
|
import LambderSessionController, { type LambderSessionCookieOptions } from "../session/LambderSessionController.js";
|
|
11
11
|
import { type LambderPublicFilesOptions } from "./LambderPublicFiles.js";
|
|
12
12
|
import { LambderFiles, type LambderFilesOption } from "./LambderFiles.js";
|
|
@@ -21,7 +21,7 @@ type MaybePromise<T> = T | Promise<T>;
|
|
|
21
21
|
type Path = `/${string}`;
|
|
22
22
|
type ActionFunction = (ctx: LambderRenderContext, resolver: LambderResolver) => MaybePromise<LambderResponse>;
|
|
23
23
|
type SessionActionFunction<SessionData = any> = (ctx: LambderSessionRenderContext<any, SessionData>, resolver: LambderResolver) => MaybePromise<LambderResponse>;
|
|
24
|
-
type HookCreatedFunction = (lambderInstance: Lambder<any, any, any, any, any>) => void | Promise<void>;
|
|
24
|
+
type HookCreatedFunction = (lambderInstance: Lambder<any, any, any, any, any, any>) => void | Promise<void>;
|
|
25
25
|
/** Return the (possibly replaced) ctx to continue, a LambderResponse to short-circuit, or an Error to fail. */
|
|
26
26
|
type HookBeforeRenderFunction = (ctx: LambderRenderContext, resolver: LambderResolver) => MaybePromise<LambderRenderContext | LambderResponse | Error>;
|
|
27
27
|
type HookAfterRenderFunction = (ctx: LambderRenderContext, resolver: LambderResolver, response: LambderResponse) => MaybePromise<LambderResponse | Error>;
|
|
@@ -118,14 +118,26 @@ export type LambderCreateOptions<TSessionData = any> = {
|
|
|
118
118
|
files?: LambderFilesOption;
|
|
119
119
|
apiPath?: string;
|
|
120
120
|
apiVersion?: string;
|
|
121
|
-
/**
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
121
|
+
/**
|
|
122
|
+
* Automatic compression for compressible responses. `true` (the default)
|
|
123
|
+
* is `{ minBytes: 860, encodings: ["br", "gzip"], quality: 5 }`; `false`
|
|
124
|
+
* disables it. `encodings` is a preference order, so `["gzip"]` opts out
|
|
125
|
+
* of Brotli for a client or CDN that mishandles it, and `quality` is the
|
|
126
|
+
* Brotli quality, the same field the at-rest stores take.
|
|
127
|
+
*/
|
|
128
|
+
compression?: LambderResponseCompressionOption;
|
|
125
129
|
/** Automatic ETag + If-None-Match 304 on GET/HEAD 200 responses. Default: true. */
|
|
126
130
|
etag?: boolean;
|
|
127
131
|
/** Guard threshold for Lambda's ~6MB response cap. Default: 5,500,000. */
|
|
128
132
|
maxResponseBytes?: number;
|
|
133
|
+
/**
|
|
134
|
+
* Ceiling on what a gzipped request payload may restore to (Lambda's
|
|
135
|
+
* ~6MB invoke cap already bounds the compressed bytes). Default:
|
|
136
|
+
* 20,000,000. Requests over it are refused rather than decompressed.
|
|
137
|
+
* The restored JSON is parsed in full before any policy or session
|
|
138
|
+
* check, so size it to the function's memory.
|
|
139
|
+
*/
|
|
140
|
+
maxRequestPayloadBytes?: number;
|
|
129
141
|
/** CORS: true allows any origin; or pass a LambderCorsConfig. Default: off. */
|
|
130
142
|
cors?: boolean | LambderCorsConfig;
|
|
131
143
|
/** DynamoDB-backed sessions; required for addSessionApi/addSessionRoute. */
|
|
@@ -134,9 +146,32 @@ export type LambderCreateOptions<TSessionData = any> = {
|
|
|
134
146
|
rateLimits?: LambderApiRateLimitsConfig<Record<string, LambderApiRateLimitPolicyConfig>>;
|
|
135
147
|
/** Named guards APIs reference (typed) via the `guards` option; build each with lambderGuard(). */
|
|
136
148
|
guards?: Record<string, LambderApiGuard<any, any, any>>;
|
|
149
|
+
/**
|
|
150
|
+
* Make an authorization declaration part of registering a session API:
|
|
151
|
+
* every addSessionApi must declare `guards`, at the type level (a
|
|
152
|
+
* missing `guards` is a compile error) and at registration (a plain-JS
|
|
153
|
+
* caller throws). An API that legitimately needs none, because the
|
|
154
|
+
* session itself is the whole authorization (the signed-in user's own
|
|
155
|
+
* account), declares a named no-op session guard, so every opt-out is
|
|
156
|
+
* explicit and one grep lists them all. Needs a guards map to pick
|
|
157
|
+
* from. Default: false.
|
|
158
|
+
*/
|
|
159
|
+
requireSessionApiGuards?: boolean;
|
|
137
160
|
/** Declarative idempotency: your store plus replay defaults; APIs opt in via `idempotency: true | { ttlSeconds }`. */
|
|
138
161
|
idempotency?: LambderApiIdempotencyConfig;
|
|
139
162
|
};
|
|
163
|
+
/**
|
|
164
|
+
* The `guards` field of a session API's options: optional by default,
|
|
165
|
+
* required once create() received requireSessionApiGuards, so that an
|
|
166
|
+
* authorization declaration cannot be forgotten at the type level.
|
|
167
|
+
*/
|
|
168
|
+
type LambderSessionGuardsField<TRequired extends boolean, TGuardsOpt> = TRequired extends true ? {
|
|
169
|
+
/** Named guards, run in declared order before input validation: a name, a list of names, or a { name: param } map for parameterized guards. Required on this instance (requireSessionApiGuards): an API the session alone authorizes declares the named no-op session guard. Their input requirements merge into this API's contract input; their return values land typed on ctx.guardData. */
|
|
170
|
+
guards: TGuardsOpt;
|
|
171
|
+
} : {
|
|
172
|
+
/** 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. */
|
|
173
|
+
guards?: TGuardsOpt;
|
|
174
|
+
};
|
|
140
175
|
/**
|
|
141
176
|
* Main Lambder class for building type-safe serverless APIs. Create
|
|
142
177
|
* instances with initLambder<SessionData>().create({...}) (see below): the
|
|
@@ -148,6 +183,7 @@ export type LambderCreateOptions<TSessionData = any> = {
|
|
|
148
183
|
* @typeParam _TRateLimitPolicies - @internal Inferred from create()'s rateLimits.policies (do not pass manually)
|
|
149
184
|
* @typeParam _TGuards - @internal Guard metadata map inferred from create()'s guards (do not pass manually)
|
|
150
185
|
* @typeParam _TIdempotencyEnabled - @internal True when create() received idempotency (do not pass manually)
|
|
186
|
+
* @typeParam _TSessionGuardsRequired - @internal True when create() received requireSessionApiGuards (do not pass manually)
|
|
151
187
|
*
|
|
152
188
|
* @example
|
|
153
189
|
* ```typescript
|
|
@@ -158,7 +194,7 @@ export type LambderCreateOptions<TSessionData = any> = {
|
|
|
158
194
|
* .addApi('createUser', { input: z.object({...}), output: z.object({...}) }, handler);
|
|
159
195
|
* ```
|
|
160
196
|
*/
|
|
161
|
-
export default class Lambder<TSessionData = any, _TContract extends Record<string, any> = {}, _TRateLimitPolicies extends Record<string, LambderApiRateLimitPolicyConfig> = {}, _TGuards extends Record<string, any> = {}, _TIdempotencyEnabled extends boolean = false> {
|
|
197
|
+
export default class Lambder<TSessionData = any, _TContract extends Record<string, any> = {}, _TRateLimitPolicies extends Record<string, LambderApiRateLimitPolicyConfig> = {}, _TGuards extends Record<string, any> = {}, _TIdempotencyEnabled extends boolean = false, _TSessionGuardsRequired extends boolean = false> {
|
|
162
198
|
apiPath: string;
|
|
163
199
|
apiVersion: null | string;
|
|
164
200
|
/** The instance's file reader (source + caches), or null without the files option. */
|
|
@@ -190,6 +226,8 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
190
226
|
private eventActionList;
|
|
191
227
|
private corsConfig;
|
|
192
228
|
private finalizeOptions;
|
|
229
|
+
private maxRequestPayloadBytes;
|
|
230
|
+
private requireSessionApiGuards;
|
|
193
231
|
private lambderSessionManager?;
|
|
194
232
|
private sessionCookieOptions;
|
|
195
233
|
private sessionTokenCookieKey;
|
|
@@ -237,7 +275,7 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
237
275
|
addRoute(condition: RegExp | ConditionFunction | LambderRouteMatcher, actionFn: ActionFunction): this;
|
|
238
276
|
addSessionRoute<TPath extends Path>(condition: TPath, actionFn: (ctx: LambderSessionRenderContext<any, TSessionData, PathParamsOf<TPath>>, resolver: LambderResolver) => MaybePromise<LambderResponse>): this;
|
|
239
277
|
addSessionRoute(condition: RegExp | ConditionFunction | LambderRouteMatcher, actionFn: SessionActionFunction<TSessionData>): this;
|
|
240
|
-
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>;
|
|
278
|
+
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, _TSessionGuardsRequired>;
|
|
241
279
|
addApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny, const TRateOpt extends LambderRateLimitOption<_TRateLimitPolicies, z.infer<TInput>, false> = never, const TGuardsOpt extends LambderGuardsOption<_TGuards, z.infer<TInput>, false> = never>(name: TName, schema: {
|
|
242
280
|
input: TInput;
|
|
243
281
|
output: TOutput;
|
|
@@ -250,20 +288,18 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
250
288
|
idempotency?: _TIdempotencyEnabled extends true ? (boolean | {
|
|
251
289
|
ttlSeconds?: number;
|
|
252
290
|
}) : never;
|
|
253
|
-
}, 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>;
|
|
291
|
+
}, 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, _TSessionGuardsRequired>;
|
|
254
292
|
addSessionApi<TName extends string, TInput extends z.ZodTypeAny, TOutput extends z.ZodTypeAny, const TRateOpt extends LambderRateLimitOption<_TRateLimitPolicies, z.infer<TInput>, true> = never, const TGuardsOpt extends LambderGuardsOption<_TGuards, z.infer<TInput>, true> = never>(name: TName, schema: {
|
|
255
293
|
input: TInput;
|
|
256
294
|
output: TOutput;
|
|
257
295
|
} & {
|
|
258
296
|
/** Named rate limits, checked in declared order before guards and validation: a name, a list of names, or a { name: true | override } map (windows overridable on perApi budgets, errorMessage on any). The first exceeded one refuses (429 envelope + Retry-After); attempts count on every counter checked before it. */
|
|
259
297
|
rateLimit?: TRateOpt;
|
|
260
|
-
/** 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. */
|
|
261
|
-
guards?: TGuardsOpt;
|
|
262
298
|
/** Replay-protect this API per client idempotencyKey. Requires the idempotency option at creation. */
|
|
263
299
|
idempotency?: _TIdempotencyEnabled extends true ? (boolean | {
|
|
264
300
|
ttlSeconds?: number;
|
|
265
301
|
}) : never;
|
|
266
|
-
}, 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>;
|
|
302
|
+
} & LambderSessionGuardsField<_TSessionGuardsRequired, TGuardsOpt>, 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, _TSessionGuardsRequired>;
|
|
267
303
|
/**
|
|
268
304
|
* Fetch the session or short-circuit the request: API calls get the
|
|
269
305
|
* protocol's { sessionExpired: true } response (handled by LambderCaller),
|
|
@@ -346,5 +382,5 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
346
382
|
export declare const initLambder: <TSessionData = any>() => {
|
|
347
383
|
create<const TOptions extends LambderCreateOptions<TSessionData>>(options: TOptions): Lambder<TSessionData, {}, TOptions["rateLimits"] extends {
|
|
348
384
|
policies: infer TPolicies extends Record<string, LambderApiRateLimitPolicyConfig>;
|
|
349
|
-
} ? TPolicies : {}, TOptions["guards"] extends Record<string, LambderApiGuard<any, any, any>> ? LambderGuardMetaMap<TOptions["guards"]> : {}, TOptions["idempotency"] extends LambderApiIdempotencyConfig ? true : false>;
|
|
385
|
+
} ? TPolicies : {}, TOptions["guards"] extends Record<string, LambderApiGuard<any, any, any>> ? LambderGuardMetaMap<TOptions["guards"]> : {}, TOptions["idempotency"] extends LambderApiIdempotencyConfig ? true : false, TOptions["requireSessionApiGuards"] extends true ? true : false>;
|
|
350
386
|
};
|
package/dist/core/Lambder.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import LambderResolver from "./LambderResolver.js";
|
|
2
2
|
import LambderResponseBuilder from "./LambderResponseBuilder.js";
|
|
3
|
-
import { LambderResponse, finalizeResponse, DEFAULT_FINALIZE_OPTIONS, } from "./LambderResponse.js";
|
|
3
|
+
import { LambderResponse, finalizeResponse, DEFAULT_FINALIZE_OPTIONS, DEFAULT_RESPONSE_COMPRESSION_SETTINGS, } from "./LambderResponse.js";
|
|
4
4
|
import { compileRouteMatcher } from "./LambderRouting.js";
|
|
5
5
|
import { applyCorsHeaders } from "./LambderCors.js";
|
|
6
6
|
import LambderSessionManager from "../session/LambderSessionManager.js";
|
|
7
|
+
import { resolveCompressionOption } from "../shared/LambderCompressionOption.js";
|
|
7
8
|
import LambderSessionController from "../session/LambderSessionController.js";
|
|
8
9
|
import { LambderPublicFilesHandler } from "./LambderPublicFiles.js";
|
|
9
10
|
import { LambderFiles } from "./LambderFiles.js";
|
|
10
11
|
import { isLambderApiError, LAMBDER_REFUSAL_CODES } from "../shared/LambderApiError.js";
|
|
11
12
|
import { LambderApiPolicyEngine } from "../policies/LambderApiPolicies.js";
|
|
12
|
-
import { createContext, isV2HttpEvent } from "./LambderContext.js";
|
|
13
|
+
import { createContext, isV2HttpEvent, restoreCompressedApiPayload } from "./LambderContext.js";
|
|
14
|
+
import { DEFAULT_MAX_REQUEST_PAYLOAD_BYTES } from "../shared/LambderRequestPayload.js";
|
|
13
15
|
/**
|
|
14
16
|
* Main Lambder class for building type-safe serverless APIs. Create
|
|
15
17
|
* instances with initLambder<SessionData>().create({...}) (see below): the
|
|
@@ -21,6 +23,7 @@ import { createContext, isV2HttpEvent } from "./LambderContext.js";
|
|
|
21
23
|
* @typeParam _TRateLimitPolicies - @internal Inferred from create()'s rateLimits.policies (do not pass manually)
|
|
22
24
|
* @typeParam _TGuards - @internal Guard metadata map inferred from create()'s guards (do not pass manually)
|
|
23
25
|
* @typeParam _TIdempotencyEnabled - @internal True when create() received idempotency (do not pass manually)
|
|
26
|
+
* @typeParam _TSessionGuardsRequired - @internal True when create() received requireSessionApiGuards (do not pass manually)
|
|
24
27
|
*
|
|
25
28
|
* @example
|
|
26
29
|
* ```typescript
|
|
@@ -63,6 +66,8 @@ export default class Lambder {
|
|
|
63
66
|
eventActionList = [];
|
|
64
67
|
corsConfig = null;
|
|
65
68
|
finalizeOptions;
|
|
69
|
+
maxRequestPayloadBytes;
|
|
70
|
+
requireSessionApiGuards;
|
|
66
71
|
lambderSessionManager;
|
|
67
72
|
sessionCookieOptions = {};
|
|
68
73
|
sessionTokenCookieKey = "LMDRSESSIONTKID";
|
|
@@ -72,13 +77,16 @@ export default class Lambder {
|
|
|
72
77
|
this.apiPath = options.apiPath ?? "/api";
|
|
73
78
|
this.apiVersion = options.apiVersion ?? null;
|
|
74
79
|
this.finalizeOptions = {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
?? DEFAULT_FINALIZE_OPTIONS.compression.minBytes },
|
|
80
|
+
// Resolved (and validated) by the same function the at-rest
|
|
81
|
+
// stores use; on unless explicitly disabled.
|
|
82
|
+
compression: resolveCompressionOption(options.compression, DEFAULT_RESPONSE_COMPRESSION_SETTINGS),
|
|
79
83
|
etag: options.etag ?? DEFAULT_FINALIZE_OPTIONS.etag,
|
|
80
84
|
maxResponseBytes: options.maxResponseBytes ?? DEFAULT_FINALIZE_OPTIONS.maxResponseBytes,
|
|
81
85
|
};
|
|
86
|
+
this.maxRequestPayloadBytes = options.maxRequestPayloadBytes ?? DEFAULT_MAX_REQUEST_PAYLOAD_BYTES;
|
|
87
|
+
if (!Number.isSafeInteger(this.maxRequestPayloadBytes) || this.maxRequestPayloadBytes <= 0) {
|
|
88
|
+
throw new Error("maxRequestPayloadBytes must be a positive integer");
|
|
89
|
+
}
|
|
82
90
|
if (options.cors !== undefined && options.cors !== false) {
|
|
83
91
|
this.corsConfig = options.cors === true ? {} : options.cors;
|
|
84
92
|
}
|
|
@@ -107,6 +115,10 @@ export default class Lambder {
|
|
|
107
115
|
this.getOrCreatePolicyEngine().addGuards(options.guards);
|
|
108
116
|
if (options.idempotency)
|
|
109
117
|
this.getOrCreatePolicyEngine().setIdempotency(options.idempotency);
|
|
118
|
+
this.requireSessionApiGuards = options.requireSessionApiGuards ?? false;
|
|
119
|
+
if (this.requireSessionApiGuards && !options.guards) {
|
|
120
|
+
throw new Error("Lambder: requireSessionApiGuards needs a guards map at creation for session APIs to declare from.");
|
|
121
|
+
}
|
|
110
122
|
}
|
|
111
123
|
setRouteFallbackHandler(routeFallbackHandler) {
|
|
112
124
|
this.routeFallbackHandler = routeFallbackHandler;
|
|
@@ -203,6 +215,10 @@ export default class Lambder {
|
|
|
203
215
|
throw new Error(`Lambder: duplicate API name "${name}". Dispatch is first-match, so the second registration would be silently dead code.`);
|
|
204
216
|
}
|
|
205
217
|
this.registeredApiNames.add(name);
|
|
218
|
+
if (mode === "session" && this.requireSessionApiGuards && options.guards === undefined) {
|
|
219
|
+
throw new Error(`Lambder: session API "${name}" declares no guards, and requireSessionApiGuards is on. ` +
|
|
220
|
+
`Declare the guard that authorizes it, or the named no-op guard that marks the session itself as the whole authorization.`);
|
|
221
|
+
}
|
|
206
222
|
const usesPolicies = options.rateLimit !== undefined || options.guards !== undefined || options.idempotency !== undefined;
|
|
207
223
|
if (!usesPolicies)
|
|
208
224
|
return;
|
|
@@ -454,6 +470,20 @@ export default class Lambder {
|
|
|
454
470
|
if (this.apiVersion && ctx._otherInternal.requestVersion && ctx._otherInternal.requestVersion !== this.apiVersion) {
|
|
455
471
|
return resolver.versionExpired();
|
|
456
472
|
}
|
|
473
|
+
// A gzipped payload is restored before anything reads it: rate-limit
|
|
474
|
+
// key slices, guards and input validation all see a plain payload.
|
|
475
|
+
if (ctx._otherInternal.isApiCall) {
|
|
476
|
+
const restored = await restoreCompressedApiPayload(ctx, this.maxRequestPayloadBytes);
|
|
477
|
+
if (!restored.ok) {
|
|
478
|
+
return resolver.api(null, {
|
|
479
|
+
errorMessage: {
|
|
480
|
+
type: "error",
|
|
481
|
+
code: LAMBDER_REFUSAL_CODES.invalidRequestPayload,
|
|
482
|
+
content: restored.message,
|
|
483
|
+
},
|
|
484
|
+
}, { statusCode: 400 });
|
|
485
|
+
}
|
|
486
|
+
}
|
|
457
487
|
let matched = null;
|
|
458
488
|
for (const action of this.actionList) {
|
|
459
489
|
const params = action.match(ctx);
|
|
@@ -58,3 +58,23 @@ export type LambderSessionRenderContext<TApiPayload = any, SessionData = any, TP
|
|
|
58
58
|
session: LambderSessionContext<SessionData>;
|
|
59
59
|
};
|
|
60
60
|
export declare const createContext: (event: LambderHttpEvent, lambdaContext: Context, apiPath: string) => LambderRenderContext;
|
|
61
|
+
/** Outcome of restoring a compressed request payload; the message is client-facing. */
|
|
62
|
+
export type LambderRestorePayloadResult = {
|
|
63
|
+
ok: true;
|
|
64
|
+
} | {
|
|
65
|
+
ok: false;
|
|
66
|
+
message: string;
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Restores a request payload the caller sent gzipped (`payloadGz` +
|
|
70
|
+
* `payloadBytes`) onto ctx.post.payload and ctx.apiPayload, so every later
|
|
71
|
+
* stage (rate-limit key slices, guards, input validation, the handler) reads
|
|
72
|
+
* an ordinary payload and needs no awareness of the wire format. A request
|
|
73
|
+
* that sent a plain payload passes through untouched.
|
|
74
|
+
*
|
|
75
|
+
* Every failure answers with a message instead of throwing: a malformed body
|
|
76
|
+
* is a client error, not a crash. The declared byte length both bounds the
|
|
77
|
+
* decompression and verifies it, so an over-large or tampered body is
|
|
78
|
+
* refused rather than expanded.
|
|
79
|
+
*/
|
|
80
|
+
export declare const restoreCompressedApiPayload: (ctx: LambderRenderContext, maxPayloadBytes: number) => Promise<LambderRestorePayloadResult>;
|