lambder 4.2.1 → 4.3.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 +33 -10
- package/dist/client.d.ts +2 -2
- package/dist/client.js +1 -1
- package/dist/core/Lambder.d.ts +9 -1
- package/dist/core/Lambder.js +3 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/policies/LambderApiIdempotency.js +3 -3
- package/dist/policies/LambderApiRateLimits.d.ts +10 -11
- package/dist/policies/LambderApiRateLimits.js +8 -5
- package/dist/session/LambderSessionManager.d.ts +26 -1
- package/dist/session/LambderSessionManager.js +40 -2
- package/dist/shared/LambderApiError.d.ts +28 -2
- package/dist/shared/LambderApiError.js +17 -0
- package/dist/stores/LambderDdbCache.js +6 -12
- package/dist/stores/LambderDdbCompression.d.ts +7 -2
- package/dist/stores/LambderDdbCompression.js +23 -8
- package/dist/stores/LambderDdbIdempotency.js +3 -11
- package/package.json +1 -1
package/Readme.md
CHANGED
|
@@ -2,12 +2,16 @@
|
|
|
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.3:**
|
|
6
|
+
|
|
7
|
+
- **Compressed sessions**: `session.data` is stored Brotli-compressed by default, as `dataBr` + `dataBytes` on the record, the same scheme LambderDdbCache and LambderDdbIdempotency use (one shared implementation). A session that caches roles, permissions or product lists shrinks 2-3x and stays within one DynamoDB read unit for longer. `session.compression` is `true` by default (the same as `{ minBytes: 0 }`: every record compressed); `false` turns it off and `{ minBytes }` compresses only from that JSON size. Records written under either setting read back, so it can be switched on or off on a live table.
|
|
8
|
+
|
|
5
9
|
**New in 4.2:**
|
|
6
10
|
|
|
7
|
-
- **Rate-limit budgets
|
|
11
|
+
- **Rate-limit budgets**: a policy's `budget` is `"perApi"` (default: each referencing API gets its own counter, so the numbers are a per-API ceiling and three APIs on a 60/min policy allow one IP 180/min in total) or `"perPolicy"` (one counter shared by every API referencing the policy). The policy is the group, and two separate shared budgets are two policies.
|
|
8
12
|
- **Per-API tuning**: the `rateLimit` option gained a map form like guards, `rateLimit: { lookupPerIp: { perMin: 20 } }`, which merges window overrides over a perApi policy's own (a tighter burst keeps the policy's daily cap). Overriding the windows of a perPolicy policy is a startup error; `errorMessage` is overridable on either.
|
|
9
13
|
- **Retry-After**: a 429 carries the exceeded window's reset as a `Retry-After` header (CORS exposes it by default via the new `exposeHeaders` option), `LambderCaller` failure outcomes surface it as `retryAfterSeconds`, `LambderDdbRateLimiter.isRateLimited()` answers `false | { window, limit, resetAt }`, and `LambderApiError`/`refuse()` accept `headers`.
|
|
10
|
-
- **One refusal shape**:
|
|
14
|
+
- **One refusal shape, with codes**: `LambderRefusalMessage` gained an optional machine-readable `code` (`refuse(content, { code })`), so clients branch and translate on an identifier instead of string-matching prose. Every refusal the framework itself authors (rate limit 429, idempotency 409 and 400, unknown API) is a `LambderRefusalMessage` stamped with a `LAMBDER_REFUSAL_CODES` constant under the reserved `lambder/` prefix; a rate-limit policy's own `errorMessage` (typed as a refusal message) inherits `lambder/rate-limited` unless it sets a code.
|
|
11
15
|
- **One validation path**: preflight slices (guard `apiInput`/`guardInput`, rate-limit `apiInput` keys) answer through `setApiInputValidationErrorHandler` exactly like the API's own schema.
|
|
12
16
|
|
|
13
17
|
**New in v4:**
|
|
@@ -302,6 +306,7 @@ const lambder = initLambder<SessionData>().create({
|
|
|
302
306
|
tableRegion: "us-east-1",
|
|
303
307
|
sessionSalt: "CHANGE-THIS-TO-A-SECURE-RANDOM-STRING",
|
|
304
308
|
enableSlidingExpiration: true, // Optional: extend session on each access
|
|
309
|
+
compression: true, // Optional: Brotli-compress session.data at rest (default true; false to disable, or { minBytes })
|
|
305
310
|
// Optionally customize cookie names (defaults: LMDRSESSIONTKID, LMDRSESSIONCSTK)
|
|
306
311
|
tokenCookieKey: "MY_SESSION_TOKEN",
|
|
307
312
|
csrfCookieKey: "MY_CSRF_TOKEN",
|
|
@@ -353,6 +358,21 @@ Semantics:
|
|
|
353
358
|
- Records created before `dataRefresh` was enabled renew on their first read.
|
|
354
359
|
- `updateSessionData()` marks data fresh (it was just written deliberately); `regenerateSession()` carries the old freshness stamp over.
|
|
355
360
|
|
|
361
|
+
#### Session data at rest (`compression`)
|
|
362
|
+
|
|
363
|
+
`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 implementation, 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.
|
|
364
|
+
|
|
365
|
+
```typescript
|
|
366
|
+
session: {
|
|
367
|
+
// ...
|
|
368
|
+
compression: true, // default: every record compressed, the same as { minBytes: 0 }
|
|
369
|
+
// compression: { minBytes: 1024 } compresses only records whose JSON is 1KB+
|
|
370
|
+
// compression: false stores data as a plain attribute
|
|
371
|
+
}
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
`quality` (Brotli 0-11, default 5) is also accepted. Reads accept both record shapes, so the setting can be switched on or off on a live table: records written under the other setting keep reading, and each is rewritten in the current shape on its next write (a sliding-expiration or `dataRefresh` write included). A compressed record that fails to decode is treated like any malformed record: no session.
|
|
375
|
+
|
|
356
376
|
#### Session Controller
|
|
357
377
|
|
|
358
378
|
Access the session controller with `lambder.getSessionController(ctx)`:
|
|
@@ -479,7 +499,7 @@ Responses are finalized once at the end of the request: automatic gzip (when the
|
|
|
479
499
|
|
|
480
500
|
### Typed API Refusals (refuse / LambderApiError)
|
|
481
501
|
|
|
482
|
-
A refusal ("you are not allowed", "quota exceeded") is not a crash. `res.die.*` covers refusals where you hold the resolver, but shared helpers (permission checks, validators) usually don't. The one-liner for the common case is `refuse()`: callable from anywhere in an API call's stack, it throws a typed refusal carrying the standard `LambderRefusalMessage` shape (`{ type, title?, content }`) that the pipeline maps onto the envelope's `errorMessage`, so refusals never pollute crash logging and clients get a parseable response:
|
|
502
|
+
A refusal ("you are not allowed", "quota exceeded") is not a crash. `res.die.*` covers refusals where you hold the resolver, but shared helpers (permission checks, validators) usually don't. The one-liner for the common case is `refuse()`: callable from anywhere in an API call's stack, it throws a typed refusal carrying the standard `LambderRefusalMessage` shape (`{ type, code?, title?, content }`) that the pipeline maps onto the envelope's `errorMessage`, so refusals never pollute crash logging and clients get a parseable response:
|
|
483
503
|
|
|
484
504
|
```typescript
|
|
485
505
|
import { refuse } from "lambder";
|
|
@@ -487,9 +507,12 @@ import { refuse } from "lambder";
|
|
|
487
507
|
if (!row) refuse("Record not found."); // { type: "warning", content }
|
|
488
508
|
if (!isAdmin) refuse("Admins only.", { notAuthorized: true }); // + envelope flag
|
|
489
509
|
refuse("Too many attempts.", { type: "error", statusCode: 429 }); // custom rendering intent + status
|
|
510
|
+
if (exists) refuse("Already reported.", { code: "ALREADY_REPORTED" }); // + machine-readable identity
|
|
490
511
|
// TypeScript applies never-return narrowing: after `if (!row) refuse(...)`, row is defined.
|
|
491
512
|
```
|
|
492
513
|
|
|
514
|
+
`code` is the refusal's identity for machines: clients branch and translate on it (a translated client never displays `content`, it looks the code up), and `content` stays the human-readable fallback for codes a client does not know yet. Keep your app's codes as one typed vocabulary in shared code. The framework stamps the refusals it authors itself with `LAMBDER_REFUSAL_CODES` (exported from `lambder` and `lambder/client`) under the reserved `lambder/` prefix, so app codes never collide: `rateLimited`, `duplicateInFlight`, `invalidIdempotencyKey`, `apiNotFound`. A rate-limit policy's own `errorMessage` inherits `lambder/rate-limited` unless it sets a code, so an `errorMessageHandler` can treat every rate limit alike and still special-case the ones you name.
|
|
515
|
+
|
|
493
516
|
For full control of the errorMessage payload (apps with their own message vocabulary), throw `LambderApiError` directly; `refuse()` is sugar over it:
|
|
494
517
|
|
|
495
518
|
```typescript
|
|
@@ -521,16 +544,16 @@ const lambder = initLambder<SessionData>().create({
|
|
|
521
544
|
apiPath: "/api",
|
|
522
545
|
// 1. Rate limiting: your limiter instance + named policies. Each policy
|
|
523
546
|
// declares its windows, what one counter tracks ("per"), and what one
|
|
524
|
-
// budget spans ("budget"
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
//
|
|
547
|
+
// budget spans ("budget"): "perApi" (default) gives every referencing
|
|
548
|
+
// API its own counter, so three APIs on a 60/min policy allow one IP
|
|
549
|
+
// 180/min in total; "perPolicy" makes every referencing API share ONE
|
|
550
|
+
// counter. The policy IS the group: separate shared budgets for, say,
|
|
551
|
+
// user APIs and report APIs are two policies.
|
|
529
552
|
rateLimits: {
|
|
530
553
|
limiter: new LambderDdbRateLimiter({ tableName: "app-rate-limiter", region: "us-east-1", failOpen: true }),
|
|
531
554
|
policies: {
|
|
532
|
-
authPerIp: { perMin: 5, perHour: 30, per: "ip"
|
|
533
|
-
writePerUser: { perMin: 30, per: "session"
|
|
555
|
+
authPerIp: { perMin: 5, perHour: 30, per: "ip" },
|
|
556
|
+
writePerUser: { perMin: 30, per: "session" }, // only referable from addSessionApi (also enforced at compile time)
|
|
534
557
|
codePerEmail: {
|
|
535
558
|
perMin: 3,
|
|
536
559
|
// ONE combined budget across every API that references this
|
package/dist/client.d.ts
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
*/
|
|
9
9
|
export { default as LambderCaller } from "./client/LambderCaller.js";
|
|
10
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";
|
|
11
|
+
export { LambderApiError, isLambderApiError, refuse, LAMBDER_REFUSAL_CODES } from "./shared/LambderApiError.js";
|
|
12
|
+
export type { LambderApiErrorOptions, LambderRefusalMessage, LambderRefusalCode, LambderRefuseOptions } from "./shared/LambderApiError.js";
|
|
13
13
|
export type { ApiContractShape, LambderApiResponse, LambderApiResponseConfig } from "./shared/LambderApiContract.js";
|
|
14
14
|
export { html, xml, raw, jsonScript, escapeHtml, renderHtmlValue, LambderSafeHtml, type LambderHtmlValue } from "./shared/LambderHtml.js";
|
|
15
15
|
export { createLambderI18n } from "./shared/LambderI18n.js";
|
package/dist/client.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
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
|
-
export { LambderApiError, isLambderApiError, refuse } from "./shared/LambderApiError.js";
|
|
13
|
+
export { LambderApiError, isLambderApiError, refuse, LAMBDER_REFUSAL_CODES } from "./shared/LambderApiError.js";
|
|
14
14
|
// Type-safe templating (tagged templates with auto-escaping)
|
|
15
15
|
export { html, xml, raw, jsonScript, escapeHtml, renderHtmlValue, LambderSafeHtml } from "./shared/LambderHtml.js";
|
|
16
16
|
// Typed translations (standalone, isomorphic)
|
package/dist/core/Lambder.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ 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 "../session/LambderSessionManager.js";
|
|
8
|
+
import { type LambderSessionDataRefreshConfig, type LambderSessionCompressionConfig } from "../session/LambderSessionManager.js";
|
|
9
9
|
import LambderSessionController, { type LambderSessionCookieOptions } from "../session/LambderSessionController.js";
|
|
10
10
|
import { type LambderPublicFilesOptions } from "./LambderPublicFiles.js";
|
|
11
11
|
import type { LambderApiGuard, LambderGuardMetaMap, LambderGuardsOption, LambderGuardDataOf, LambderGuardInputsOf } from "../policies/LambderApiGuards.js";
|
|
@@ -88,6 +88,14 @@ export type LambderSessionOptions<TSessionData = any> = {
|
|
|
88
88
|
* semantics.
|
|
89
89
|
*/
|
|
90
90
|
dataRefresh?: LambderSessionDataRefreshConfig<TSessionData>;
|
|
91
|
+
/**
|
|
92
|
+
* Brotli compression of session.data at rest. `true` (the default)
|
|
93
|
+
* compresses every record, the same as `{ minBytes: 0 }`; `false` turns
|
|
94
|
+
* it off; `{ minBytes }` compresses only records whose JSON is at least
|
|
95
|
+
* that many bytes. Records written under either setting read back, so
|
|
96
|
+
* it can be switched on or off on a live table.
|
|
97
|
+
*/
|
|
98
|
+
compression?: boolean | LambderSessionCompressionConfig;
|
|
91
99
|
};
|
|
92
100
|
/**
|
|
93
101
|
* Everything an instance is configured with, in ONE declaration: base
|
package/dist/core/Lambder.js
CHANGED
|
@@ -6,7 +6,7 @@ import { applyCorsHeaders } from "./LambderCors.js";
|
|
|
6
6
|
import LambderSessionManager from "../session/LambderSessionManager.js";
|
|
7
7
|
import LambderSessionController from "../session/LambderSessionController.js";
|
|
8
8
|
import { LambderPublicFilesHandler } from "./LambderPublicFiles.js";
|
|
9
|
-
import { isLambderApiError } from "../shared/LambderApiError.js";
|
|
9
|
+
import { isLambderApiError, LAMBDER_REFUSAL_CODES } from "../shared/LambderApiError.js";
|
|
10
10
|
import { LambderApiPolicyEngine } from "../policies/LambderApiPolicies.js";
|
|
11
11
|
import { createContext, isV2HttpEvent } from "./LambderContext.js";
|
|
12
12
|
/**
|
|
@@ -90,6 +90,7 @@ export default class Lambder {
|
|
|
90
90
|
enableSlidingExpiration: session.enableSlidingExpiration,
|
|
91
91
|
slidingWriteIntervalSeconds: session.slidingWriteIntervalSeconds,
|
|
92
92
|
dataRefresh: session.dataRefresh,
|
|
93
|
+
compression: session.compression,
|
|
93
94
|
});
|
|
94
95
|
this.sessionCookieOptions = session.cookie ?? {};
|
|
95
96
|
if (session.tokenCookieKey)
|
|
@@ -423,7 +424,7 @@ export default class Lambder {
|
|
|
423
424
|
if (isAPI) {
|
|
424
425
|
if (this.apiFallbackHandler)
|
|
425
426
|
return await this.apiFallbackHandler(ctx, resolver);
|
|
426
|
-
return resolver.api(null, { errorMessage: { type: "warning", content: "API not found." } });
|
|
427
|
+
return resolver.api(null, { errorMessage: { type: "warning", code: LAMBDER_REFUSAL_CODES.apiNotFound, content: "API not found." } });
|
|
427
428
|
}
|
|
428
429
|
if (this.publicFilesHandler) {
|
|
429
430
|
const fileResponse = await this.publicFilesHandler.handle(ctx);
|
package/dist/index.d.ts
CHANGED
|
@@ -3,8 +3,8 @@ export default Lambder;
|
|
|
3
3
|
export { initLambder } from './core/Lambder.js';
|
|
4
4
|
export { default as LambderCaller } from "./client/LambderCaller.js";
|
|
5
5
|
export type { LambderApiOutcome, LambderApiFailureReason, LambderCallOptions, LambderIdempotencyKeyScope } from "./client/LambderCaller.js";
|
|
6
|
-
export { LambderApiError, isLambderApiError, refuse } from "./shared/LambderApiError.js";
|
|
7
|
-
export type { LambderApiErrorOptions, LambderRefusalMessage, LambderRefuseOptions } from "./shared/LambderApiError.js";
|
|
6
|
+
export { LambderApiError, isLambderApiError, refuse, LAMBDER_REFUSAL_CODES } from "./shared/LambderApiError.js";
|
|
7
|
+
export type { LambderApiErrorOptions, LambderRefusalMessage, LambderRefusalCode, LambderRefuseOptions } from "./shared/LambderApiError.js";
|
|
8
8
|
export { default as LambderResponseBuilder } from "./core/LambderResponseBuilder.js";
|
|
9
9
|
export { default as LambderResolver } from "./core/LambderResolver.js";
|
|
10
10
|
export { default as LambderSessionManager } from "./session/LambderSessionManager.js";
|
|
@@ -18,7 +18,7 @@ export type { LambderRouteMatcher, LambderCorsConfig, LambderCreateOptions, Lamb
|
|
|
18
18
|
export { LambderPublicFilesHandler } from "./core/LambderPublicFiles.js";
|
|
19
19
|
export type { LambderPublicFilesOptions } from "./core/LambderPublicFiles.js";
|
|
20
20
|
export type { LambderSessionCookieOptions } from "./session/LambderSessionController.js";
|
|
21
|
-
export type { LambderSessionContext, LambderCreatedSession, LambderSessionDataRefreshConfig } from "./session/LambderSessionManager.js";
|
|
21
|
+
export type { LambderSessionContext, LambderCreatedSession, LambderSessionDataRefreshConfig, LambderSessionCompressionConfig } from "./session/LambderSessionManager.js";
|
|
22
22
|
export { LambderSessionDataRefreshError, LambderSessionReadError } from "./session/LambderSessionManager.js";
|
|
23
23
|
export { LambderDdbCache } from "./stores/LambderDdbCache.js";
|
|
24
24
|
export type { LambderDdbCacheOptions, LambderDdbCacheSetOptions, LambderDdbCacheGetOrSetOptions, } from "./stores/LambderDdbCache.js";
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ export default Lambder;
|
|
|
3
3
|
export { initLambder } from './core/Lambder.js';
|
|
4
4
|
export { default as LambderCaller } from "./client/LambderCaller.js";
|
|
5
5
|
// Typed API refusals (isomorphic: shared code may throw them from anywhere)
|
|
6
|
-
export { LambderApiError, isLambderApiError, refuse } from "./shared/LambderApiError.js";
|
|
6
|
+
export { LambderApiError, isLambderApiError, refuse, LAMBDER_REFUSAL_CODES } from "./shared/LambderApiError.js";
|
|
7
7
|
export { default as LambderResponseBuilder } from "./core/LambderResponseBuilder.js";
|
|
8
8
|
export { default as LambderResolver } from "./core/LambderResolver.js";
|
|
9
9
|
export { default as LambderSessionManager } from "./session/LambderSessionManager.js";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { LambderApiError } from "../shared/LambderApiError.js";
|
|
1
|
+
import { LambderApiError, LAMBDER_REFUSAL_CODES } from "../shared/LambderApiError.js";
|
|
2
2
|
import { LambderResponse, normalizeHeaders } from "../core/LambderResponse.js";
|
|
3
3
|
/** A crashed original must not block retries forever: pending claims expire on their own. */
|
|
4
4
|
const IDEMPOTENCY_PENDING_TTL_SECONDS = 300;
|
|
@@ -40,7 +40,7 @@ export class LambderApiIdempotencyEngine {
|
|
|
40
40
|
const content = `Invalid idempotency key: must be a string of ${IDEMPOTENCY_MIN_KEY_LENGTH}-${IDEMPOTENCY_MAX_KEY_LENGTH} characters.`;
|
|
41
41
|
throw new LambderApiError(content, {
|
|
42
42
|
statusCode: 400,
|
|
43
|
-
errorMessage: { type: "error", content },
|
|
43
|
+
errorMessage: { type: "error", code: LAMBDER_REFUSAL_CODES.invalidIdempotencyKey, content },
|
|
44
44
|
});
|
|
45
45
|
}
|
|
46
46
|
return rawKey;
|
|
@@ -116,7 +116,7 @@ export class LambderApiIdempotencyEngine {
|
|
|
116
116
|
if (begun.state === "pending") {
|
|
117
117
|
throw new LambderApiError(`Duplicate request for "${apiName}": the original is still processing.`, {
|
|
118
118
|
statusCode: 409,
|
|
119
|
-
errorMessage: { type: "warning", content: "This request is already being processed." },
|
|
119
|
+
errorMessage: { type: "warning", code: LAMBDER_REFUSAL_CODES.duplicateInFlight, content: "This request is already being processed." },
|
|
120
120
|
});
|
|
121
121
|
}
|
|
122
122
|
if (begun.state === "done") {
|
|
@@ -42,13 +42,12 @@ export declare function lambderRateLimitKey(key: {
|
|
|
42
42
|
/** What one rate-limit counter tracks: the client IP, the session identity, or a custom payload-derived key. */
|
|
43
43
|
export type LambderRateLimitPer = "ip" | "session" | LambderRateLimitKeyFn<any>;
|
|
44
44
|
/**
|
|
45
|
-
* What one budget spans
|
|
46
|
-
* says what its numbers mean:
|
|
45
|
+
* What one budget spans:
|
|
47
46
|
*
|
|
48
|
-
* - "perApi": every API referencing the policy gets its own
|
|
49
|
-
* windows are a per-API ceiling (three APIs referencing a
|
|
50
|
-
* allow one subject 180/min in total). An API may tune the
|
|
51
|
-
* declaration: `rateLimit: { name: { perMin: 20 } }`.
|
|
47
|
+
* - "perApi" (default): every API referencing the policy gets its own
|
|
48
|
+
* counter, so the windows are a per-API ceiling (three APIs referencing a
|
|
49
|
+
* 60/min policy allow one subject 180/min in total). An API may tune the
|
|
50
|
+
* windows in its declaration: `rateLimit: { name: { perMin: 20 } }`.
|
|
52
51
|
* - "perPolicy": every API referencing the policy shares ONE counter, so the
|
|
53
52
|
* windows are one combined budget (e.g. one per-email allowance across
|
|
54
53
|
* send, register, and reset). The policy IS the group: to give user APIs
|
|
@@ -58,9 +57,9 @@ export type LambderRateLimitBudget = "perApi" | "perPolicy";
|
|
|
58
57
|
/** A named rate-limit policy: fixed windows, the key one counter tracks, and what one budget spans. */
|
|
59
58
|
export type LambderApiRateLimitPolicyConfig = LambderRateLimitPolicy & {
|
|
60
59
|
per: LambderRateLimitPer;
|
|
61
|
-
/** Whether the windows are a per-API ceiling or one budget shared by every referencing API. See LambderRateLimitBudget. */
|
|
62
|
-
budget
|
|
63
|
-
/** Envelope errorMessage for refused requests. Default: a warning saying too many requests. */
|
|
60
|
+
/** Whether the windows are a per-API ceiling (default) or one budget shared by every referencing API. See LambderRateLimitBudget. */
|
|
61
|
+
budget?: LambderRateLimitBudget;
|
|
62
|
+
/** Envelope errorMessage for refused requests; inherits code "lambder/rate-limited" unless it sets its own. Default: a warning saying too many requests. */
|
|
64
63
|
errorMessage?: LambderRefusalMessage;
|
|
65
64
|
};
|
|
66
65
|
export type LambderApiRateLimitsConfig<TPolicies extends Record<string, LambderApiRateLimitPolicyConfig>> = {
|
|
@@ -94,8 +93,8 @@ export type LambderRateLimitOverride = LambderRateLimitPolicy & {
|
|
|
94
93
|
errorMessage?: LambderRefusalMessage;
|
|
95
94
|
};
|
|
96
95
|
type LambderRateLimitOverrideFor<TPolicy> = TPolicy extends {
|
|
97
|
-
budget: "
|
|
98
|
-
} ?
|
|
96
|
+
budget: "perPolicy";
|
|
97
|
+
} ? Pick<LambderRateLimitOverride, "errorMessage"> : LambderRateLimitOverride;
|
|
99
98
|
/**
|
|
100
99
|
* The per-API `rateLimit` option: one policy name, an ordered list of names,
|
|
101
100
|
* or an object map that can carry each policy's override (`true` applies the
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { RATE_LIMIT_WINDOWS } from "../stores/LambderDdbRateLimiter.js";
|
|
2
|
-
import { LambderApiError } from "../shared/LambderApiError.js";
|
|
2
|
+
import { LambderApiError, LAMBDER_REFUSAL_CODES } from "../shared/LambderApiError.js";
|
|
3
3
|
import { parsePreflightSlice } from "./LambderApiGuards.js";
|
|
4
4
|
const RATE_LIMIT_WINDOW_KEYS = RATE_LIMIT_WINDOWS.map((window) => window.key);
|
|
5
5
|
/** Refusal a rate-limited request answers unless the policy or the API's override names its own. */
|
|
6
|
-
const DEFAULT_RATE_LIMIT_REFUSAL = { type: "warning", content: "Too many requests. Please try again later." };
|
|
6
|
+
const DEFAULT_RATE_LIMIT_REFUSAL = { type: "warning", code: LAMBDER_REFUSAL_CODES.rateLimited, content: "Too many requests. Please try again later." };
|
|
7
7
|
export function lambderRateLimitKey(key) { return key; }
|
|
8
8
|
/** Normalize the three rateLimit-option forms into ordered entries; an explicit `undefined` map value declares nothing. */
|
|
9
9
|
const toRateLimitEntries = (value) => {
|
|
@@ -41,8 +41,8 @@ export class LambderApiRateLimitsEngine {
|
|
|
41
41
|
throw new Error(`Lambder: rate-limit policy "${name}" declares no window (${RATE_LIMIT_WINDOW_KEYS.join("/")}).`);
|
|
42
42
|
}
|
|
43
43
|
const budget = policy.budget;
|
|
44
|
-
if (budget !== "perApi" && budget !== "perPolicy") {
|
|
45
|
-
throw new Error(`Lambder: rate-limit policy "${name}"
|
|
44
|
+
if (budget !== undefined && budget !== "perApi" && budget !== "perPolicy") {
|
|
45
|
+
throw new Error(`Lambder: rate-limit policy "${name}" has budget "${String(budget)}"; use "perApi" (default: each referencing API counts separately) or "perPolicy" (one counter shared by every referencing API).`);
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
this.limiter = config.limiter;
|
|
@@ -91,8 +91,11 @@ export class LambderApiRateLimitsEngine {
|
|
|
91
91
|
const exceeded = await this.limiter.isRateLimited(trackerKey, limits);
|
|
92
92
|
if (exceeded) {
|
|
93
93
|
const retryAfterSeconds = Math.max(1, exceeded.resetAt - Math.floor(Date.now() / 1000));
|
|
94
|
+
// A policy's (or override's) own message inherits the framework
|
|
95
|
+
// code unless it sets a more specific one of its own.
|
|
96
|
+
const message = override?.errorMessage ?? policy.errorMessage;
|
|
94
97
|
throw new LambderApiError(`Rate limited: "${apiName}" exceeded policy "${name}" (${exceeded.window}: ${exceeded.limit}).`, {
|
|
95
|
-
errorMessage:
|
|
98
|
+
errorMessage: message ? { code: LAMBDER_REFUSAL_CODES.rateLimited, ...message } : DEFAULT_RATE_LIMIT_REFUSAL,
|
|
96
99
|
statusCode: 429,
|
|
97
100
|
headers: { "Retry-After": String(retryAfterSeconds) },
|
|
98
101
|
});
|
|
@@ -53,6 +53,23 @@ export type LambderSessionDataRefreshConfig<SessionData = any> = {
|
|
|
53
53
|
*/
|
|
54
54
|
refresh: (session: LambderSessionContext<SessionData>) => Promise<SessionData | null>;
|
|
55
55
|
};
|
|
56
|
+
/**
|
|
57
|
+
* Brotli compression of session.data at rest. The option is `true` by
|
|
58
|
+
* default, which equals `{ minBytes: 0 }`: every record compressed. A
|
|
59
|
+
* compressed record carries the data's JSON as Brotli bytes (`dataBr`)
|
|
60
|
+
* beside its byte length (`dataBytes`), the scheme LambderDdbCache and
|
|
61
|
+
* LambderDdbIdempotency use. Below minBytes, or with compression off, the
|
|
62
|
+
* record keeps a plain `data` attribute. Reads accept both shapes, so the
|
|
63
|
+
* setting can be switched on or off on a live table: records written under
|
|
64
|
+
* the other setting keep reading, and each is rewritten in the current
|
|
65
|
+
* shape on its next write.
|
|
66
|
+
*/
|
|
67
|
+
export type LambderSessionCompressionConfig = {
|
|
68
|
+
/** JSON byte length from which data is stored compressed. Default: 0 (always). */
|
|
69
|
+
minBytes?: number;
|
|
70
|
+
/** Brotli quality (0-11), like LambderDdbCache. Default: 5. */
|
|
71
|
+
quality?: number;
|
|
72
|
+
};
|
|
56
73
|
/**
|
|
57
74
|
* Wraps errors thrown by the dataRefresh callback so they stay
|
|
58
75
|
* distinguishable from "no session": fetchSessionIfExists() swallows missing
|
|
@@ -81,7 +98,8 @@ export default class LambderSessionManager {
|
|
|
81
98
|
private enableSlidingExpiration;
|
|
82
99
|
private slidingWriteIntervalSeconds;
|
|
83
100
|
private dataRefresh;
|
|
84
|
-
|
|
101
|
+
private compression;
|
|
102
|
+
constructor({ tableName, tableRegion, partitionKey, sortKey, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, dataRefresh, compression, }: {
|
|
85
103
|
tableName: string;
|
|
86
104
|
tableRegion: string;
|
|
87
105
|
partitionKey: string;
|
|
@@ -90,6 +108,7 @@ export default class LambderSessionManager {
|
|
|
90
108
|
enableSlidingExpiration?: boolean;
|
|
91
109
|
slidingWriteIntervalSeconds?: number;
|
|
92
110
|
dataRefresh?: LambderSessionDataRefreshConfig;
|
|
111
|
+
compression?: boolean | LambderSessionCompressionConfig;
|
|
93
112
|
});
|
|
94
113
|
private sessionUserKeyHasher;
|
|
95
114
|
/**
|
|
@@ -101,6 +120,12 @@ export default class LambderSessionManager {
|
|
|
101
120
|
private hashToken;
|
|
102
121
|
private constantTimeCompare;
|
|
103
122
|
private ddbGetItem;
|
|
123
|
+
/**
|
|
124
|
+
* Persists a session record. With compression on, `data` is stored as
|
|
125
|
+
* Brotli bytes (`dataBr`) beside its JSON byte length (`dataBytes`)
|
|
126
|
+
* once the JSON reaches minBytes; otherwise it stays a plain attribute.
|
|
127
|
+
* See LambderSessionCompressionConfig.
|
|
128
|
+
*/
|
|
104
129
|
private ddbPutItem;
|
|
105
130
|
private ddbDeleteItem;
|
|
106
131
|
private ddbQueryAllByPartitionKey;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import crypto from "crypto";
|
|
2
2
|
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
3
3
|
import { DynamoDBDocumentClient, QueryCommand, DeleteCommand, PutCommand, GetCommand } from "@aws-sdk/lib-dynamodb";
|
|
4
|
+
import { brotliCompressText, brotliRestoreText } from "../stores/LambderDdbCompression.js";
|
|
4
5
|
/**
|
|
5
6
|
* Wraps errors thrown by the dataRefresh callback so they stay
|
|
6
7
|
* distinguishable from "no session": fetchSessionIfExists() swallows missing
|
|
@@ -35,7 +36,8 @@ export default class LambderSessionManager {
|
|
|
35
36
|
enableSlidingExpiration;
|
|
36
37
|
slidingWriteIntervalSeconds;
|
|
37
38
|
dataRefresh;
|
|
38
|
-
|
|
39
|
+
compression;
|
|
40
|
+
constructor({ tableName, tableRegion, partitionKey, sortKey, sessionSalt, enableSlidingExpiration = true, slidingWriteIntervalSeconds, dataRefresh, compression = true, }) {
|
|
39
41
|
this.tableName = tableName;
|
|
40
42
|
this.sessionSalt = sessionSalt;
|
|
41
43
|
this.partitionKey = partitionKey;
|
|
@@ -43,6 +45,19 @@ export default class LambderSessionManager {
|
|
|
43
45
|
this.enableSlidingExpiration = enableSlidingExpiration;
|
|
44
46
|
this.slidingWriteIntervalSeconds = slidingWriteIntervalSeconds ?? null;
|
|
45
47
|
this.dataRefresh = dataRefresh ?? null;
|
|
48
|
+
const compressionConfig = compression === true ? {} : compression;
|
|
49
|
+
this.compression = compressionConfig ? {
|
|
50
|
+
minBytes: compressionConfig.minBytes ?? 0,
|
|
51
|
+
quality: compressionConfig.quality ?? 5,
|
|
52
|
+
} : null;
|
|
53
|
+
if (this.compression) {
|
|
54
|
+
if (!Number.isSafeInteger(this.compression.minBytes) || this.compression.minBytes < 0) {
|
|
55
|
+
throw new Error("compression.minBytes must be a non-negative integer");
|
|
56
|
+
}
|
|
57
|
+
if (!Number.isInteger(this.compression.quality) || this.compression.quality < 0 || this.compression.quality > 11) {
|
|
58
|
+
throw new Error("compression.quality must be an integer from 0 to 11");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
46
61
|
const ddbClient = new DynamoDBClient({ region: tableRegion });
|
|
47
62
|
this.ddbDocumentClient = DynamoDBDocumentClient.from(ddbClient);
|
|
48
63
|
}
|
|
@@ -74,7 +89,22 @@ export default class LambderSessionManager {
|
|
|
74
89
|
return null;
|
|
75
90
|
}
|
|
76
91
|
;
|
|
77
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Persists a session record. With compression on, `data` is stored as
|
|
94
|
+
* Brotli bytes (`dataBr`) beside its JSON byte length (`dataBytes`)
|
|
95
|
+
* once the JSON reaches minBytes; otherwise it stays a plain attribute.
|
|
96
|
+
* See LambderSessionCompressionConfig.
|
|
97
|
+
*/
|
|
98
|
+
async ddbPutItem(session) {
|
|
99
|
+
const { data, ...item } = session;
|
|
100
|
+
const raw = this.compression && Buffer.from(JSON.stringify(data), "utf8");
|
|
101
|
+
if (this.compression && raw && raw.byteLength >= this.compression.minBytes) {
|
|
102
|
+
item.dataBr = await brotliCompressText(raw, this.compression.quality);
|
|
103
|
+
item.dataBytes = raw.byteLength;
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
item.data = data;
|
|
107
|
+
}
|
|
78
108
|
return await this.ddbDocumentClient.send(new PutCommand({ TableName: this.tableName, Item: item, }));
|
|
79
109
|
}
|
|
80
110
|
;
|
|
@@ -169,6 +199,14 @@ export default class LambderSessionManager {
|
|
|
169
199
|
}
|
|
170
200
|
if (!session)
|
|
171
201
|
return null;
|
|
202
|
+
// A compressed record (see ddbPutItem) decodes back into `data`. One
|
|
203
|
+
// that fails to decode throws, which the controller treats like any
|
|
204
|
+
// malformed record: no session.
|
|
205
|
+
if (session.dataBr) {
|
|
206
|
+
session.data = JSON.parse(await brotliRestoreText(session.dataBr, session.dataBytes));
|
|
207
|
+
delete session.dataBr;
|
|
208
|
+
delete session.dataBytes;
|
|
209
|
+
}
|
|
172
210
|
if (!session.csrfTokenHash)
|
|
173
211
|
return null;
|
|
174
212
|
if (!session.sessionKey)
|
|
@@ -57,17 +57,43 @@ export declare class LambderApiError extends Error {
|
|
|
57
57
|
export declare const isLambderApiError: (err: unknown) => err is LambderApiError;
|
|
58
58
|
/**
|
|
59
59
|
* The standard shape refusals carry on the envelope's errorMessage field.
|
|
60
|
-
*
|
|
61
|
-
*
|
|
60
|
+
* `code` is the refusal's machine-readable identity: clients branch and
|
|
61
|
+
* translate on it and never string-match `content`, which stays the
|
|
62
|
+
* human-readable fallback for codes a client does not know yet. Apps keep
|
|
63
|
+
* their own typed code vocabulary; the framework's own refusals carry a
|
|
64
|
+
* LambderRefusalCode. The caller's errorMessageHandler receives the object
|
|
65
|
+
* as-is; apps with their own errorMessage vocabulary can keep using
|
|
66
|
+
* LambderApiError directly instead.
|
|
62
67
|
*/
|
|
63
68
|
export type LambderRefusalMessage = {
|
|
64
69
|
type: "warning" | "error" | "info";
|
|
70
|
+
/** Machine-readable identity of the refusal (the app's own vocabulary, or a LambderRefusalCode). */
|
|
71
|
+
code?: string;
|
|
65
72
|
title?: string;
|
|
66
73
|
content: string;
|
|
67
74
|
};
|
|
75
|
+
/**
|
|
76
|
+
* Codes the framework stamps on the refusals it authors itself, under the
|
|
77
|
+
* reserved `lambder/` prefix so app codes never collide. Compare against
|
|
78
|
+
* these constants on the client (exported from `lambder/client` too) rather
|
|
79
|
+
* than retyping the strings.
|
|
80
|
+
*/
|
|
81
|
+
export declare const LAMBDER_REFUSAL_CODES: {
|
|
82
|
+
/** A rate-limit policy refused (429). A policy's own errorMessage inherits this unless it sets a code. */
|
|
83
|
+
readonly rateLimited: "lambder/rate-limited";
|
|
84
|
+
/** The original of an idempotent request is still processing (409). */
|
|
85
|
+
readonly duplicateInFlight: "lambder/duplicate-in-flight";
|
|
86
|
+
/** The idempotencyKey is malformed (400). */
|
|
87
|
+
readonly invalidIdempotencyKey: "lambder/invalid-idempotency-key";
|
|
88
|
+
/** No API is registered under the requested name. */
|
|
89
|
+
readonly apiNotFound: "lambder/api-not-found";
|
|
90
|
+
};
|
|
91
|
+
export type LambderRefusalCode = (typeof LAMBDER_REFUSAL_CODES)[keyof typeof LAMBDER_REFUSAL_CODES];
|
|
68
92
|
export type LambderRefuseOptions = {
|
|
69
93
|
/** Rendering intent for the client's errorMessageHandler. Default: "warning". */
|
|
70
94
|
type?: LambderRefusalMessage["type"];
|
|
95
|
+
/** Machine-readable identity of the refusal, for clients to branch and translate on. */
|
|
96
|
+
code?: string;
|
|
71
97
|
/** Optional heading shown above the content. */
|
|
72
98
|
title?: string;
|
|
73
99
|
/** Sets the envelope's notAuthorized flag (routed to the caller's notAuthorizedHandler). */
|
|
@@ -38,6 +38,22 @@ export class LambderApiError extends Error {
|
|
|
38
38
|
}
|
|
39
39
|
/** Brand-based type guard (see LambderApiError.isLambderApiError). */
|
|
40
40
|
export const isLambderApiError = (err) => err instanceof Error && err.isLambderApiError === true;
|
|
41
|
+
/**
|
|
42
|
+
* Codes the framework stamps on the refusals it authors itself, under the
|
|
43
|
+
* reserved `lambder/` prefix so app codes never collide. Compare against
|
|
44
|
+
* these constants on the client (exported from `lambder/client` too) rather
|
|
45
|
+
* than retyping the strings.
|
|
46
|
+
*/
|
|
47
|
+
export const LAMBDER_REFUSAL_CODES = {
|
|
48
|
+
/** A rate-limit policy refused (429). A policy's own errorMessage inherits this unless it sets a code. */
|
|
49
|
+
rateLimited: "lambder/rate-limited",
|
|
50
|
+
/** The original of an idempotent request is still processing (409). */
|
|
51
|
+
duplicateInFlight: "lambder/duplicate-in-flight",
|
|
52
|
+
/** The idempotencyKey is malformed (400). */
|
|
53
|
+
invalidIdempotencyKey: "lambder/invalid-idempotency-key",
|
|
54
|
+
/** No API is registered under the requested name. */
|
|
55
|
+
apiNotFound: "lambder/api-not-found",
|
|
56
|
+
};
|
|
41
57
|
/**
|
|
42
58
|
* Refuse the current API call: a routine business "no" (not found, invalid
|
|
43
59
|
* input, not allowed) with a user-facing message. Throws a LambderApiError
|
|
@@ -54,6 +70,7 @@ export const refuse = (content, options = {}) => {
|
|
|
54
70
|
throw new LambderApiError(content, {
|
|
55
71
|
errorMessage: {
|
|
56
72
|
type: options.type ?? "warning",
|
|
73
|
+
...(options.code !== undefined ? { code: options.code } : {}),
|
|
57
74
|
...(options.title !== undefined ? { title: options.title } : {}),
|
|
58
75
|
content,
|
|
59
76
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { BatchWriteItemCommand, DeleteItemCommand, DynamoDBClient, GetItemCommand, PutItemCommand, QueryCommand, } from "@aws-sdk/client-dynamodb";
|
|
2
2
|
import { getCrypto } from "../shared/node-polyfills.js";
|
|
3
|
-
import { brotliCompressText,
|
|
3
|
+
import { brotliCompressText, brotliRestoreText } from "./LambderDdbCompression.js";
|
|
4
4
|
import { LRUCache } from "lru-cache";
|
|
5
5
|
const DEFAULT_TTL_SECONDS = 365 * 24 * 60 * 60;
|
|
6
6
|
const DEFAULT_CHUNK_BYTES = 350 * 1024;
|
|
@@ -14,7 +14,8 @@ const MAX_BATCH_RETRIES = 8;
|
|
|
14
14
|
// Node builtins are loaded lazily through node-polyfills so this module can
|
|
15
15
|
// sit in a frontend bundle's import graph (via the package root) without
|
|
16
16
|
// breaking; using the cache at runtime still requires Node. Brotli helpers
|
|
17
|
-
// are shared with LambderDdbIdempotency via
|
|
17
|
+
// are shared with LambderDdbIdempotency and LambderSessionManager via
|
|
18
|
+
// ./LambderDdbCompression.js.
|
|
18
19
|
const requireCrypto = async () => {
|
|
19
20
|
const crypto = await getCrypto();
|
|
20
21
|
if (!crypto)
|
|
@@ -96,10 +97,7 @@ export class LambderDdbCache {
|
|
|
96
97
|
const nowSeconds = this.nowSeconds();
|
|
97
98
|
if (cached && cached.expiresAt > nowSeconds) {
|
|
98
99
|
try {
|
|
99
|
-
|
|
100
|
-
if (output.length === cached.uncompressedBytes) {
|
|
101
|
-
return JSON.parse(output.toString("utf8"));
|
|
102
|
-
}
|
|
100
|
+
return JSON.parse(await brotliRestoreText(cached.compressed, cached.uncompressedBytes));
|
|
103
101
|
}
|
|
104
102
|
catch {
|
|
105
103
|
// Fall through to DynamoDB; the in-memory copy is disposable.
|
|
@@ -119,13 +117,9 @@ export class LambderDdbCache {
|
|
|
119
117
|
if (await sha256(compressed) !== manifest.checksum) {
|
|
120
118
|
throw new Error("compressed checksum does not match manifest");
|
|
121
119
|
}
|
|
122
|
-
const
|
|
123
|
-
if (output.length !== manifest.uncompressedBytes) {
|
|
124
|
-
throw new Error("uncompressed byte length does not match manifest");
|
|
125
|
-
}
|
|
126
|
-
const json = output.toString("utf8");
|
|
120
|
+
const json = await brotliRestoreText(compressed, manifest.uncompressedBytes);
|
|
127
121
|
const parsed = JSON.parse(json);
|
|
128
|
-
this.remember(normalizedKey, compressed,
|
|
122
|
+
this.remember(normalizedKey, compressed, manifest.uncompressedBytes, manifest.expiresAt);
|
|
129
123
|
return parsed;
|
|
130
124
|
}
|
|
131
125
|
catch (error) {
|
|
@@ -1,3 +1,8 @@
|
|
|
1
1
|
export declare const brotliCompressText: (input: Buffer, quality: number) => Promise<Buffer>;
|
|
2
|
-
/**
|
|
3
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Restores text stored as Brotli bytes beside its declared UTF-8 byte length.
|
|
4
|
+
* The length bounds the decompression and the output must match it exactly,
|
|
5
|
+
* so a truncated or tampered record fails instead of decoding to something
|
|
6
|
+
* else.
|
|
7
|
+
*/
|
|
8
|
+
export declare const brotliRestoreText: (compressed: Uint8Array, declaredBytes: number) => Promise<string>;
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { getZlib } from "../shared/node-polyfills.js";
|
|
2
2
|
// Brotli compression shared by the DynamoDB-backed stores (LambderDdbCache,
|
|
3
|
-
// LambderDdbIdempotency). Values they persist are text
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// LambderDdbIdempotency, LambderSessionManager). Values they persist are text
|
|
4
|
+
// (JSON), so TEXT mode, and they all store it the same way: the Brotli bytes
|
|
5
|
+
// beside the text's original UTF-8 byte length, which bounds the decompression
|
|
6
|
+
// (a corrupt record cannot balloon memory) and verifies it. zlib is loaded
|
|
7
|
+
// lazily through node-polyfills so these modules can sit in a frontend
|
|
8
|
+
// bundle's import graph (via the package root) without breaking.
|
|
6
9
|
const requireZlib = async () => {
|
|
7
10
|
const zlib = await getZlib();
|
|
8
11
|
if (!zlib)
|
|
@@ -25,15 +28,27 @@ export const brotliCompressText = async (input, quality) => {
|
|
|
25
28
|
});
|
|
26
29
|
});
|
|
27
30
|
};
|
|
28
|
-
/**
|
|
29
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Restores text stored as Brotli bytes beside its declared UTF-8 byte length.
|
|
33
|
+
* The length bounds the decompression and the output must match it exactly,
|
|
34
|
+
* so a truncated or tampered record fails instead of decoding to something
|
|
35
|
+
* else.
|
|
36
|
+
*/
|
|
37
|
+
export const brotliRestoreText = async (compressed, declaredBytes) => {
|
|
38
|
+
if (!Number.isSafeInteger(declaredBytes) || declaredBytes <= 0) {
|
|
39
|
+
throw new Error("compressed record is missing its byte length");
|
|
40
|
+
}
|
|
30
41
|
const zlib = await requireZlib();
|
|
31
|
-
|
|
32
|
-
zlib.brotliDecompress(
|
|
42
|
+
const output = await new Promise((resolve, reject) => {
|
|
43
|
+
zlib.brotliDecompress(Buffer.from(compressed), { maxOutputLength: declaredBytes }, (error, result) => {
|
|
33
44
|
if (error)
|
|
34
45
|
reject(error);
|
|
35
46
|
else
|
|
36
|
-
resolve(
|
|
47
|
+
resolve(result);
|
|
37
48
|
});
|
|
38
49
|
});
|
|
50
|
+
if (output.length !== declaredBytes) {
|
|
51
|
+
throw new Error("decompressed length does not match the record");
|
|
52
|
+
}
|
|
53
|
+
return output.toString("utf8");
|
|
39
54
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import crypto from "crypto";
|
|
2
2
|
import { DynamoDBClient, PutItemCommand, GetItemCommand, DeleteItemCommand, } from "@aws-sdk/client-dynamodb";
|
|
3
|
-
import { brotliCompressText,
|
|
3
|
+
import { brotliCompressText, brotliRestoreText } from "./LambderDdbCompression.js";
|
|
4
4
|
/** Bodies at or above this size are stored Brotli-compressed; smaller ones stay plain. */
|
|
5
5
|
const COMPRESS_MIN_BYTES = 1024;
|
|
6
6
|
/**
|
|
@@ -67,16 +67,8 @@ export class LambderDdbIdempotency {
|
|
|
67
67
|
/** A stored item's response body: plain (`body`) or Brotli (`bodyBr` + `bodyBytes`). */
|
|
68
68
|
static async readItemBody(item) {
|
|
69
69
|
const compressed = item.bodyBr?.B;
|
|
70
|
-
if (compressed)
|
|
71
|
-
|
|
72
|
-
if (!declaredBytes)
|
|
73
|
-
throw new Error("LambderDdbIdempotency: compressed record is missing bodyBytes.");
|
|
74
|
-
const output = await brotliDecompressText(Buffer.from(compressed), declaredBytes);
|
|
75
|
-
if (output.length !== declaredBytes) {
|
|
76
|
-
throw new Error("LambderDdbIdempotency: stored body length does not match its record.");
|
|
77
|
-
}
|
|
78
|
-
return output.toString("utf8");
|
|
79
|
-
}
|
|
70
|
+
if (compressed)
|
|
71
|
+
return await brotliRestoreText(compressed, Number(item.bodyBytes?.N ?? 0));
|
|
80
72
|
return item.body?.S ?? "";
|
|
81
73
|
}
|
|
82
74
|
/**
|