lambder 4.3.1 → 4.3.2

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 CHANGED
@@ -6,6 +6,8 @@ Lambder is a highly opinionated dynamic serverless framework designed to facilit
6
6
 
7
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
8
 
9
+ - **One compression option everywhere**: `LambderDdbCache`, `LambderDdbIdempotency` and sessions take the same `compression` option (`true` for that store's defaults, `false` for off, `{ minBytes, quality }` to override), resolved by one shared function, and each store records a value's encoding so the option can be switched on or off on a live table. Defaults keep the previous behavior: the cache compresses everything, the idempotency store from 1KB. `compressionQuality` on the cache and idempotency store is replaced by `compression: { quality }`, and HTTP `compression` accepts `true` as `{ minBytes: 860 }`.
10
+
9
11
  **New in 4.2:**
10
12
 
11
13
  - **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.
@@ -371,7 +373,7 @@ session: {
371
373
  }
372
374
  ```
373
375
 
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.
376
+ `quality` (Brotli 0-11, default 5) is also accepted; the option (`LambderCompressionOption`) is the same one `LambderDdbCache` and `LambderDdbIdempotency` take. 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
377
 
376
378
  #### Session Controller
377
379
 
@@ -664,13 +666,13 @@ Request flow per API: session (session APIs) → idempotency replay lookup → r
664
666
 
665
667
  Preflight input slices (guard `apiInput`/`guardInput` values, rate-limit `apiInput` keys) answer a rejection through the same path as the API's own schema: `setApiInputValidationErrorHandler` when set, otherwise the standard 422 body. One failure, one shape, whichever schema rejected it.
666
668
 
667
- **Idempotency semantics**: the client sends an `idempotencyKey` per call (see LambderCaller below); generate it once per logical operation with `LambderCaller.createIdempotencyKey()` and reuse it on retries. Keys must be 16-200 characters and UNGUESSABLE random (shorter keys refuse with 400): on session APIs the scope is session + API name + key, and on public APIs it is the key itself + API name, deliberately NOT the client IP, because the retry idempotency exists for (a timeout followed by a network switch) frequently arrives from a new IP. Concurrent duplicates of an in-flight request refuse with 409, repeats of a completed one replay the stored response verbatim until the TTL (response headers included, so headers set via `res.setHeader`/`res.addHeader` replay too), and a crashed original releases its claim so a retry actually retries. The replay rule for failures: RESPONSES are stored and replayed, refusals returned as envelopes (`res.api(null, { errorMessage })`) and thrown responses (`res.die.*`) included; EXCEPTIONS are not, so a thrown `LambderApiError`/`refuse()` releases the claim and a retry re-executes and decides afresh. Stored bodies of 1KB or more are Brotli-compressed (the same scheme as LambderDdbCache; `compressionQuality` on the store, default 5): JSON envelopes typically shrink 5-10x, which cuts DynamoDB write cost, and the ~350KB item budget applies to the COMPRESSED bytes, so even large responses usually stay replayable. Responses with status ≥ 500, bodies over the budget even compressed, and responses that set cookies are never stored (replaying one request's Set-Cookie, e.g. session tokens, into another would be wrong; such APIs still get in-flight 409 dedupe, just not replays). Claims are owner-checked, so an original that stalls past the pending window can no longer overwrite or delete the claim a retry has since taken. Requests without a key execute normally.
669
+ **Idempotency semantics**: the client sends an `idempotencyKey` per call (see LambderCaller below); generate it once per logical operation with `LambderCaller.createIdempotencyKey()` and reuse it on retries. Keys must be 16-200 characters and UNGUESSABLE random (shorter keys refuse with 400): on session APIs the scope is session + API name + key, and on public APIs it is the key itself + API name, deliberately NOT the client IP, because the retry idempotency exists for (a timeout followed by a network switch) frequently arrives from a new IP. Concurrent duplicates of an in-flight request refuse with 409, repeats of a completed one replay the stored response verbatim until the TTL (response headers included, so headers set via `res.setHeader`/`res.addHeader` replay too), and a crashed original releases its claim so a retry actually retries. The replay rule for failures: RESPONSES are stored and replayed, refusals returned as envelopes (`res.api(null, { errorMessage })`) and thrown responses (`res.die.*`) included; EXCEPTIONS are not, so a thrown `LambderApiError`/`refuse()` releases the claim and a retry re-executes and decides afresh. Stored bodies of 1KB or more are Brotli-compressed by default (the same scheme and `compression` option as LambderDdbCache: `true`, `false`, or `{ minBytes, quality }`, default `{ minBytes: 1024, quality: 5 }`; records of either shape read back, so it can be switched on a live table): JSON envelopes typically shrink 5-10x, which cuts DynamoDB write cost, and the ~350KB item budget applies to the COMPRESSED bytes, so even large responses usually stay replayable. Responses with status ≥ 500, bodies over the budget even compressed, and responses that set cookies are never stored (replaying one request's Set-Cookie, e.g. session tokens, into another would be wrong; such APIs still get in-flight 409 dedupe, just not replays). Claims are owner-checked, so an original that stalls past the pending window can no longer overwrite or delete the claim a retry has since taken. Requests without a key execute normally.
668
670
 
669
671
  Also enforced at registration: **duplicate API names throw** (dispatch is first-match, so a second registration of the same name would be silently dead code).
670
672
 
671
673
  ### DynamoDB Cache (LambderDdbCache)
672
674
 
673
- Standalone, persistent JSON cache backed by a DynamoDB table (`pk`/`sk` keys + `expiresAt` TTL attribute, same shape as the session table). Items are prefixed `CACHE#<namespace>#`, and the rate limiter (`RL#`) and idempotency store (`IDEM#`) prefix theirs too, so all three non-session systems can share one table without collisions; keep sessions in their own table for IAM scoping. Brotli-compressed values, in-memory LRU layer, single-flight deduplication, a DynamoDB lease so only one Lambda fills a missing key, and fail-open semantics. Server-only. **Full guide with table setup: [docs/DDB_CACHE.md](./docs/DDB_CACHE.md).**
675
+ Standalone, persistent JSON cache backed by a DynamoDB table (`pk`/`sk` keys + `expiresAt` TTL attribute, same shape as the session table). Items are prefixed `CACHE#<namespace>#`, and the rate limiter (`RL#`) and idempotency store (`IDEM#`) prefix theirs too, so all three non-session systems can share one table without collisions; keep sessions in their own table for IAM scoping. Brotli-compressed values (the shared `compression` option), in-memory LRU layer, single-flight deduplication, a DynamoDB lease so only one Lambda fills a missing key, and fail-open semantics. Server-only. **Full guide with table setup: [docs/DDB_CACHE.md](./docs/DDB_CACHE.md).**
674
676
 
675
677
  ```typescript
676
678
  import { LambderDdbCache } from "lambder";
@@ -5,7 +5,8 @@ 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, type LambderSessionCompressionConfig } from "../session/LambderSessionManager.js";
8
+ import { type LambderSessionDataRefreshConfig } from "../session/LambderSessionManager.js";
9
+ import type { LambderCompressionOption } from "../stores/LambderDdbCompression.js";
9
10
  import LambderSessionController, { type LambderSessionCookieOptions } from "../session/LambderSessionController.js";
10
11
  import { type LambderPublicFilesOptions } from "./LambderPublicFiles.js";
11
12
  import type { LambderApiGuard, LambderGuardMetaMap, LambderGuardsOption, LambderGuardDataOf, LambderGuardInputsOf } from "../policies/LambderApiGuards.js";
@@ -95,7 +96,7 @@ export type LambderSessionOptions<TSessionData = any> = {
95
96
  * that many bytes. Records written under either setting read back, so
96
97
  * it can be switched on or off on a live table.
97
98
  */
98
- compression?: boolean | LambderSessionCompressionConfig;
99
+ compression?: LambderCompressionOption;
99
100
  };
100
101
  /**
101
102
  * Everything an instance is configured with, in ONE declaration: base
@@ -109,8 +110,8 @@ export type LambderCreateOptions<TSessionData = any> = {
109
110
  publicPath?: string;
110
111
  apiPath?: string;
111
112
  apiVersion?: string;
112
- /** Automatic gzip for compressible responses. Default: { minBytes: 860 }. Set false to disable. */
113
- compression?: false | {
113
+ /** Automatic gzip for compressible responses. `true` (the default) is `{ minBytes: 860 }`; `false` disables it. */
114
+ compression?: boolean | {
114
115
  minBytes?: number;
115
116
  };
116
117
  /** Automatic ETag + If-None-Match 304 on GET/HEAD 200 responses. Default: true. */
@@ -72,7 +72,8 @@ export default class Lambder {
72
72
  this.finalizeOptions = {
73
73
  compression: options.compression === false
74
74
  ? false
75
- : { minBytes: options.compression?.minBytes ?? DEFAULT_FINALIZE_OPTIONS.compression.minBytes },
75
+ : { minBytes: (typeof options.compression === "object" ? options.compression.minBytes : undefined)
76
+ ?? DEFAULT_FINALIZE_OPTIONS.compression.minBytes },
76
77
  etag: options.etag ?? DEFAULT_FINALIZE_OPTIONS.etag,
77
78
  maxResponseBytes: options.maxResponseBytes ?? DEFAULT_FINALIZE_OPTIONS.maxResponseBytes,
78
79
  };
package/dist/index.d.ts CHANGED
@@ -18,7 +18,8 @@ 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, LambderSessionCompressionConfig } from "./session/LambderSessionManager.js";
21
+ export type { LambderSessionContext, LambderCreatedSession, LambderSessionDataRefreshConfig } from "./session/LambderSessionManager.js";
22
+ export type { LambderCompressionOption, LambderCompressionConfig } from "./stores/LambderDdbCompression.js";
22
23
  export { LambderSessionDataRefreshError, LambderSessionReadError } from "./session/LambderSessionManager.js";
23
24
  export { LambderDdbCache } from "./stores/LambderDdbCache.js";
24
25
  export type { LambderDdbCacheOptions, LambderDdbCacheSetOptions, LambderDdbCacheGetOrSetOptions, } from "./stores/LambderDdbCache.js";
@@ -1,3 +1,4 @@
1
+ import { type LambderCompressionOption } from "../stores/LambderDdbCompression.js";
1
2
  export type LambderSessionContext<SessionData = any> = {
2
3
  [x: string]: any;
3
4
  /**
@@ -53,23 +54,6 @@ export type LambderSessionDataRefreshConfig<SessionData = any> = {
53
54
  */
54
55
  refresh: (session: LambderSessionContext<SessionData>) => Promise<SessionData | null>;
55
56
  };
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
- };
73
57
  /**
74
58
  * Wraps errors thrown by the dataRefresh callback so they stay
75
59
  * distinguishable from "no session": fetchSessionIfExists() swallows missing
@@ -108,7 +92,7 @@ export default class LambderSessionManager {
108
92
  enableSlidingExpiration?: boolean;
109
93
  slidingWriteIntervalSeconds?: number;
110
94
  dataRefresh?: LambderSessionDataRefreshConfig;
111
- compression?: boolean | LambderSessionCompressionConfig;
95
+ compression?: LambderCompressionOption;
112
96
  });
113
97
  private sessionUserKeyHasher;
114
98
  /**
@@ -124,7 +108,6 @@ export default class LambderSessionManager {
124
108
  * Persists a session record. With compression on, `data` is stored as
125
109
  * Brotli bytes (`dataBr`) beside its JSON byte length (`dataBytes`)
126
110
  * once the JSON reaches minBytes; otherwise it stays a plain attribute.
127
- * See LambderSessionCompressionConfig.
128
111
  */
129
112
  private ddbPutItem;
130
113
  private ddbDeleteItem;
@@ -1,7 +1,16 @@
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
+ import { brotliCompressText, brotliRestoreText, resolveCompressionOption, } from "../stores/LambderDdbCompression.js";
5
+ /**
6
+ * Session compression defaults: every record compressed (see
7
+ * LambderCompressionOption for the option's shape and toggle semantics).
8
+ * A compressed record carries the data's JSON as Brotli bytes (`dataBr`)
9
+ * beside its byte length (`dataBytes`), the scheme LambderDdbCache and
10
+ * LambderDdbIdempotency use; below minBytes, or with compression off, the
11
+ * record keeps a plain `data` attribute.
12
+ */
13
+ const SESSION_COMPRESSION_DEFAULTS = { minBytes: 0, quality: 5 };
5
14
  /**
6
15
  * Wraps errors thrown by the dataRefresh callback so they stay
7
16
  * distinguishable from "no session": fetchSessionIfExists() swallows missing
@@ -37,7 +46,7 @@ export default class LambderSessionManager {
37
46
  slidingWriteIntervalSeconds;
38
47
  dataRefresh;
39
48
  compression;
40
- constructor({ tableName, tableRegion, partitionKey, sortKey, sessionSalt, enableSlidingExpiration = true, slidingWriteIntervalSeconds, dataRefresh, compression = true, }) {
49
+ constructor({ tableName, tableRegion, partitionKey, sortKey, sessionSalt, enableSlidingExpiration = true, slidingWriteIntervalSeconds, dataRefresh, compression, }) {
41
50
  this.tableName = tableName;
42
51
  this.sessionSalt = sessionSalt;
43
52
  this.partitionKey = partitionKey;
@@ -45,19 +54,7 @@ export default class LambderSessionManager {
45
54
  this.enableSlidingExpiration = enableSlidingExpiration;
46
55
  this.slidingWriteIntervalSeconds = slidingWriteIntervalSeconds ?? null;
47
56
  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
- }
57
+ this.compression = resolveCompressionOption(compression, SESSION_COMPRESSION_DEFAULTS);
61
58
  const ddbClient = new DynamoDBClient({ region: tableRegion });
62
59
  this.ddbDocumentClient = DynamoDBDocumentClient.from(ddbClient);
63
60
  }
@@ -93,7 +90,6 @@ export default class LambderSessionManager {
93
90
  * Persists a session record. With compression on, `data` is stored as
94
91
  * Brotli bytes (`dataBr`) beside its JSON byte length (`dataBytes`)
95
92
  * once the JSON reaches minBytes; otherwise it stays a plain attribute.
96
- * See LambderSessionCompressionConfig.
97
93
  */
98
94
  async ddbPutItem(session) {
99
95
  const { data, ...item } = session;
@@ -1,4 +1,5 @@
1
1
  import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
2
+ import { type LambderCompressionOption } from "./LambderDdbCompression.js";
2
3
  export interface LambderDdbCacheOptions {
3
4
  tableName: string;
4
5
  region?: string;
@@ -7,7 +8,13 @@ export interface LambderDdbCacheOptions {
7
8
  namespace?: string;
8
9
  defaultTtlSeconds?: number;
9
10
  chunkBytes?: number;
10
- compressionQuality?: number;
11
+ /**
12
+ * Brotli compression of stored values. `true` (the default) is
13
+ * `{ minBytes: 0, quality: 5 }`: every value compressed; `false` stores
14
+ * values plain; an object overrides the defaults. The manifest records
15
+ * each value's encoding, so it can be switched on or off on a live table.
16
+ */
17
+ compression?: LambderCompressionOption;
11
18
  maxValueBytes?: number;
12
19
  memoryMaxBytes?: number;
13
20
  client?: DynamoDBClient;
@@ -22,8 +29,10 @@ export interface LambderDdbCacheGetOrSetOptions extends LambderDdbCacheSetOption
22
29
  /**
23
30
  * Persistent JSON cache backed by DynamoDB.
24
31
  *
25
- * Values are Brotli-compressed. Values within the safe DynamoDB item budget are
26
- * stored directly in the manifest for a single-request read; larger values are
32
+ * Values are Brotli-compressed by default (`compression` option; the manifest
33
+ * records each value's encoding, so the option can be switched on a live
34
+ * table). Values within the safe DynamoDB item budget are stored directly in
35
+ * the manifest for a single-request read; larger values are
27
36
  * split into versioned binary chunks. A manifest is written only after every
28
37
  * chunk succeeds, so readers see either the previous complete version or the
29
38
  * new complete version. DynamoDB TTL is cleanup only; every read also checks
@@ -41,7 +50,7 @@ export declare class LambderDdbCache {
41
50
  private readonly client;
42
51
  private readonly defaultTtlSeconds;
43
52
  private readonly chunkBytes;
44
- private readonly compressionQuality;
53
+ private readonly compression;
45
54
  private readonly maxValueBytes;
46
55
  private readonly memory;
47
56
  private readonly inFlight;
@@ -64,6 +73,8 @@ export declare class LambderDdbCache {
64
73
  private readChunks;
65
74
  private invalidateManifest;
66
75
  private batchWrite;
76
+ /** The JSON text of a stored payload. */
77
+ private decode;
67
78
  private remember;
68
79
  private normalizeKey;
69
80
  private partitionKey;
@@ -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, brotliRestoreText } from "./LambderDdbCompression.js";
3
+ import { brotliCompressText, brotliRestoreText, resolveCompressionOption, } 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;
@@ -11,6 +11,8 @@ const META_SORT_KEY = "meta";
11
11
  const LOCK_SORT_KEY = "lock";
12
12
  const BATCH_WRITE_LIMIT = 25;
13
13
  const MAX_BATCH_RETRIES = 8;
14
+ /** Every value compressed by default; see the `compression` option. */
15
+ const COMPRESSION_DEFAULTS = { minBytes: 0, quality: 5 };
14
16
  // Node builtins are loaded lazily through node-polyfills so this module can
15
17
  // sit in a frontend bundle's import graph (via the package root) without
16
18
  // breaking; using the cache at runtime still requires Node. Brotli helpers
@@ -40,8 +42,10 @@ const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, mil
40
42
  /**
41
43
  * Persistent JSON cache backed by DynamoDB.
42
44
  *
43
- * Values are Brotli-compressed. Values within the safe DynamoDB item budget are
44
- * stored directly in the manifest for a single-request read; larger values are
45
+ * Values are Brotli-compressed by default (`compression` option; the manifest
46
+ * records each value's encoding, so the option can be switched on a live
47
+ * table). Values within the safe DynamoDB item budget are stored directly in
48
+ * the manifest for a single-request read; larger values are
45
49
  * split into versioned binary chunks. A manifest is written only after every
46
50
  * chunk succeeds, so readers see either the previous complete version or the
47
51
  * new complete version. DynamoDB TTL is cleanup only; every read also checks
@@ -59,7 +63,7 @@ export class LambderDdbCache {
59
63
  client;
60
64
  defaultTtlSeconds;
61
65
  chunkBytes;
62
- compressionQuality;
66
+ compression;
63
67
  maxValueBytes;
64
68
  memory;
65
69
  inFlight = new Map();
@@ -77,17 +81,14 @@ export class LambderDdbCache {
77
81
  if (this.chunkBytes > MAX_SAFE_CHUNK_BYTES) {
78
82
  throw new Error(`chunkBytes must not exceed ${MAX_SAFE_CHUNK_BYTES}`);
79
83
  }
80
- this.compressionQuality = options.compressionQuality ?? 5;
81
- if (!Number.isInteger(this.compressionQuality) || this.compressionQuality < 0 || this.compressionQuality > 11) {
82
- throw new Error("compressionQuality must be an integer from 0 to 11");
83
- }
84
+ this.compression = resolveCompressionOption(options.compression, COMPRESSION_DEFAULTS);
84
85
  this.maxValueBytes = positiveInteger(options.maxValueBytes ?? DEFAULT_MAX_VALUE_BYTES, "maxValueBytes");
85
86
  const memoryMaxBytes = options.memoryMaxBytes ?? DEFAULT_MEMORY_BYTES;
86
87
  this.memory = memoryMaxBytes === 0
87
88
  ? null
88
89
  : new LRUCache({
89
90
  maxSize: positiveInteger(memoryMaxBytes, "memoryMaxBytes"),
90
- sizeCalculation: (entry) => entry.compressed.length,
91
+ sizeCalculation: (entry) => entry.stored.length,
91
92
  });
92
93
  this.client = options.client ?? new DynamoDBClient({ region: options.region ?? "us-east-1" });
93
94
  }
@@ -97,7 +98,7 @@ export class LambderDdbCache {
97
98
  const nowSeconds = this.nowSeconds();
98
99
  if (cached && cached.expiresAt > nowSeconds) {
99
100
  try {
100
- return JSON.parse(await brotliRestoreText(cached.compressed, cached.uncompressedBytes));
101
+ return JSON.parse(await this.decode(cached.stored, cached.encoding, cached.uncompressedBytes));
101
102
  }
102
103
  catch {
103
104
  // Fall through to DynamoDB; the in-memory copy is disposable.
@@ -110,16 +111,16 @@ export class LambderDdbCache {
110
111
  if (!manifest || manifest.expiresAt <= nowSeconds)
111
112
  return undefined;
112
113
  try {
113
- const compressed = manifest.inlineData ?? await this.readChunks(pk, manifest);
114
- if (compressed.length !== manifest.compressedBytes) {
115
- throw new Error("compressed byte length does not match manifest");
114
+ const stored = manifest.inlineData ?? await this.readChunks(pk, manifest);
115
+ if (stored.length !== manifest.storedBytes) {
116
+ throw new Error("stored byte length does not match manifest");
116
117
  }
117
- if (await sha256(compressed) !== manifest.checksum) {
118
- throw new Error("compressed checksum does not match manifest");
118
+ if (await sha256(stored) !== manifest.checksum) {
119
+ throw new Error("stored checksum does not match manifest");
119
120
  }
120
- const json = await brotliRestoreText(compressed, manifest.uncompressedBytes);
121
+ const json = await this.decode(stored, manifest.encoding, manifest.uncompressedBytes);
121
122
  const parsed = JSON.parse(json);
122
- this.remember(normalizedKey, compressed, manifest.uncompressedBytes, manifest.expiresAt);
123
+ this.remember(normalizedKey, stored, manifest.encoding, manifest.uncompressedBytes, manifest.expiresAt);
123
124
  return parsed;
124
125
  }
125
126
  catch (error) {
@@ -149,18 +150,20 @@ export class LambderDdbCache {
149
150
  if (input.length > this.maxValueBytes) {
150
151
  throw new Error(`Cache value exceeds maxValueBytes (${input.length} > ${this.maxValueBytes})`);
151
152
  }
152
- const compressed = await brotliCompressText(input, this.compressionQuality);
153
- if (compressed.length > this.maxValueBytes) {
154
- throw new Error(`Compressed cache value exceeds maxValueBytes (${compressed.length} > ${this.maxValueBytes})`);
153
+ const brotli = this.compression && input.length >= this.compression.minBytes ? this.compression : null;
154
+ const encoding = brotli ? "br" : "identity";
155
+ const stored = brotli ? await brotliCompressText(input, brotli.quality) : input;
156
+ if (stored.length > this.maxValueBytes) {
157
+ throw new Error(`Stored cache value exceeds maxValueBytes (${stored.length} > ${this.maxValueBytes})`);
155
158
  }
156
159
  const pk = await this.partitionKey(normalizedKey);
157
160
  const version = `${Date.now().toString(36)}-${await randomUUID()}`;
158
161
  const expiresAt = this.nowSeconds() + ttlSeconds;
159
162
  const chunks = [];
160
- const inline = compressed.length <= this.chunkBytes;
163
+ const inline = stored.length <= this.chunkBytes;
161
164
  if (!inline) {
162
- for (let offset = 0; offset < compressed.length; offset += this.chunkBytes) {
163
- chunks.push(compressed.subarray(offset, offset + this.chunkBytes));
165
+ for (let offset = 0; offset < stored.length; offset += this.chunkBytes) {
166
+ chunks.push(stored.subarray(offset, offset + this.chunkBytes));
164
167
  }
165
168
  }
166
169
  const writes = chunks.map((chunk, index) => ({
@@ -181,16 +184,16 @@ export class LambderDdbCache {
181
184
  sk: { S: META_SORT_KEY },
182
185
  version: { S: version },
183
186
  chunkCount: { N: String(chunks.length) },
184
- compressedBytes: { N: String(compressed.length) },
187
+ storedBytes: { N: String(stored.length) },
185
188
  uncompressedBytes: { N: String(input.length) },
186
- checksum: { S: await sha256(compressed) },
187
- encoding: { S: "br" },
189
+ checksum: { S: await sha256(stored) },
190
+ encoding: { S: encoding },
188
191
  createdAt: { N: String(this.nowSeconds()) },
189
192
  expiresAt: { N: String(expiresAt) },
190
- ...(inline ? { data: { B: compressed } } : {}),
193
+ ...(inline ? { data: { B: stored } } : {}),
191
194
  },
192
195
  }));
193
- this.remember(normalizedKey, compressed, input.length, expiresAt);
196
+ this.remember(normalizedKey, stored, encoding, input.length, expiresAt);
194
197
  }
195
198
  async delete(key) {
196
199
  const normalizedKey = this.normalizeKey(key);
@@ -344,37 +347,39 @@ export class LambderDdbCache {
344
347
  const version = item.version?.S;
345
348
  const encoding = item.encoding?.S;
346
349
  const chunkCount = Number(item.chunkCount?.N);
347
- const compressedBytes = Number(item.compressedBytes?.N);
350
+ const storedBytes = Number(item.storedBytes?.N);
348
351
  const uncompressedBytes = Number(item.uncompressedBytes?.N);
349
352
  const expiresAt = Number(item.expiresAt?.N);
350
353
  const checksum = item.checksum?.S;
351
354
  const inlineData = item.data?.B == null ? undefined : Buffer.from(item.data.B);
352
355
  const validInline = inlineData !== undefined &&
353
356
  chunkCount === 0 &&
354
- inlineData.length === compressedBytes &&
355
- compressedBytes <= this.chunkBytes;
357
+ inlineData.length === storedBytes &&
358
+ storedBytes <= this.chunkBytes;
356
359
  const validChunks = inlineData === undefined &&
357
360
  chunkCount > 0 &&
358
- chunkCount === Math.ceil(compressedBytes / this.chunkBytes);
361
+ chunkCount === Math.ceil(storedBytes / this.chunkBytes);
359
362
  if (!version ||
360
- encoding !== "br" ||
363
+ (encoding !== "br" && encoding !== "identity") ||
361
364
  !checksum ||
362
365
  !Number.isSafeInteger(chunkCount) ||
363
366
  chunkCount < 0 ||
364
- !Number.isSafeInteger(compressedBytes) ||
365
- compressedBytes < 0 ||
366
- compressedBytes > this.maxValueBytes ||
367
+ !Number.isSafeInteger(storedBytes) ||
368
+ storedBytes < 0 ||
369
+ storedBytes > this.maxValueBytes ||
367
370
  !Number.isSafeInteger(uncompressedBytes) ||
368
371
  uncompressedBytes < 0 ||
369
372
  uncompressedBytes > this.maxValueBytes ||
373
+ (encoding === "identity" && storedBytes !== uncompressedBytes) ||
370
374
  !Number.isSafeInteger(expiresAt) ||
371
375
  (!validInline && !validChunks)) {
372
376
  return undefined;
373
377
  }
374
378
  return {
375
379
  version,
380
+ encoding,
376
381
  chunkCount,
377
- compressedBytes,
382
+ storedBytes,
378
383
  uncompressedBytes,
379
384
  checksum,
380
385
  expiresAt,
@@ -411,7 +416,7 @@ export class LambderDdbCache {
411
416
  throw new Error(`DynamoDB cache entry has an invalid chunk index at ${index}`);
412
417
  }
413
418
  }
414
- return Buffer.concat(chunks.map((chunk) => chunk.data), manifest.compressedBytes);
419
+ return Buffer.concat(chunks.map((chunk) => chunk.data), manifest.storedBytes);
415
420
  }
416
421
  async invalidateManifest(pk, version) {
417
422
  try {
@@ -445,13 +450,17 @@ export class LambderDdbCache {
445
450
  }
446
451
  }
447
452
  }
448
- remember(key, compressed, uncompressedBytes, expiresAt) {
453
+ /** The JSON text of a stored payload. */
454
+ async decode(stored, encoding, uncompressedBytes) {
455
+ return encoding === "br" ? await brotliRestoreText(stored, uncompressedBytes) : stored.toString("utf8");
456
+ }
457
+ remember(key, stored, encoding, uncompressedBytes, expiresAt) {
449
458
  if (!this.memory)
450
459
  return;
451
460
  const ttl = expiresAt * 1000 - Date.now();
452
461
  if (ttl <= 0)
453
462
  return;
454
- this.memory.set(key, { compressed, uncompressedBytes, expiresAt }, { ttl });
463
+ this.memory.set(key, { stored, encoding, uncompressedBytes, expiresAt }, { ttl });
455
464
  }
456
465
  normalizeKey(key) {
457
466
  if (typeof key !== "string" || !key.trim())
@@ -1,3 +1,20 @@
1
+ /**
2
+ * The compression option every store shares. `true` is on with the store's
3
+ * defaults, `false` is off, an object overrides the defaults: `minBytes` is
4
+ * the UTF-8 size from which a value is stored compressed (0: always),
5
+ * `quality` is Brotli 0-11. Values below minBytes are stored plain, and a
6
+ * store reads records of either shape, so the option can be switched on or
7
+ * off on a live table: records written under the other setting keep
8
+ * reading, and each is rewritten in the current shape on its next write.
9
+ */
10
+ export type LambderCompressionConfig = {
11
+ minBytes?: number;
12
+ quality?: number;
13
+ };
14
+ export type LambderCompressionOption = boolean | LambderCompressionConfig;
15
+ export type LambderCompressionSettings = Required<LambderCompressionConfig>;
16
+ /** Resolves a store's compression option against its defaults: null when off. */
17
+ export declare const resolveCompressionOption: (option: LambderCompressionOption | undefined, defaults: LambderCompressionSettings) => LambderCompressionSettings | null;
1
18
  export declare const brotliCompressText: (input: Buffer, quality: number) => Promise<Buffer>;
2
19
  /**
3
20
  * Restores text stored as Brotli bytes beside its declared UTF-8 byte length.
@@ -1,11 +1,18 @@
1
1
  import { getZlib } from "../shared/node-polyfills.js";
2
- // Brotli compression shared by the DynamoDB-backed stores (LambderDdbCache,
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.
2
+ /** Resolves a store's compression option against its defaults: null when off. */
3
+ export const resolveCompressionOption = (option, defaults) => {
4
+ if (option === false)
5
+ return null;
6
+ const config = option === true || option === undefined ? {} : option;
7
+ const settings = { minBytes: config.minBytes ?? defaults.minBytes, quality: config.quality ?? defaults.quality };
8
+ if (!Number.isSafeInteger(settings.minBytes) || settings.minBytes < 0) {
9
+ throw new Error("compression.minBytes must be a non-negative integer");
10
+ }
11
+ if (!Number.isInteger(settings.quality) || settings.quality < 0 || settings.quality > 11) {
12
+ throw new Error("compression.quality must be an integer from 0 to 11");
13
+ }
14
+ return settings;
15
+ };
9
16
  const requireZlib = async () => {
10
17
  const zlib = await getZlib();
11
18
  if (!zlib)
@@ -1,11 +1,17 @@
1
1
  import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
2
+ import { type LambderCompressionOption } from "./LambderDdbCompression.js";
2
3
  export interface LambderDdbIdempotencyOptions {
3
4
  tableName: string;
4
5
  region?: string;
5
6
  /** Partition key prefix, keeps records separated from other systems in a shared table. Default: "IDEM". */
6
7
  keyPrefix?: string;
7
- /** Brotli quality (0-11) for stored bodies, like LambderDdbCache. Default: 5. */
8
- compressionQuality?: number;
8
+ /**
9
+ * Brotli compression of stored bodies. `true` (the default) is
10
+ * `{ minBytes: 1024, quality: 5 }`; `false` stores every body plain; an
11
+ * object overrides the defaults. Records of either shape read back, so
12
+ * it can be switched on or off on a live table.
13
+ */
14
+ compression?: LambderCompressionOption;
9
15
  client?: DynamoDBClient;
10
16
  }
11
17
  export type LambderIdempotencyDoneRecord = {
@@ -35,10 +41,11 @@ export type LambderIdempotencyBeginResult = {
35
41
  * and loses the scope to a retry can no longer overwrite or delete the
36
42
  * retry's claim (both settle calls become silent no-ops instead).
37
43
  *
38
- * Stored bodies of 1KB or more are Brotli-compressed (same scheme as
39
- * LambderDdbCache): the bodies are JSON envelopes that typically shrink
40
- * 5-10x, which cuts DynamoDB write units and lets large responses fit the
41
- * item budget instead of skipping replay storage.
44
+ * Stored bodies are Brotli-compressed from 1KB by default (same scheme as
45
+ * LambderDdbCache, see the `compression` option): the bodies are JSON
46
+ * envelopes that typically shrink 5-10x, which cuts DynamoDB write units
47
+ * and lets large responses fit the item budget instead of skipping replay
48
+ * storage.
42
49
  *
43
50
  * Table shape: string hash key `pk`, string range key `sk`, TTL on
44
51
  * `expiresAt`. Items are prefixed `IDEM#` by default, so the table can be
@@ -48,7 +55,7 @@ export type LambderIdempotencyBeginResult = {
48
55
  export declare class LambderDdbIdempotency {
49
56
  readonly tableName: string;
50
57
  readonly keyPrefix: string;
51
- private readonly compressionQuality;
58
+ private readonly compression;
52
59
  private readonly client;
53
60
  constructor(options: LambderDdbIdempotencyOptions);
54
61
  private itemKey;
@@ -74,10 +81,11 @@ export declare class LambderDdbIdempotency {
74
81
  }): Promise<LambderIdempotencyBeginResult>;
75
82
  /**
76
83
  * Store the response for replays, overwriting the pending claim. Bodies
77
- * of COMPRESS_MIN_BYTES or more are stored Brotli-compressed (they are
78
- * JSON envelopes, which typically shrink 5-10x), cutting DynamoDB write
79
- * units and letting large responses fit the item budget; smaller bodies
80
- * stay plain. Returns:
84
+ * from the compression option's minBytes are stored Brotli-compressed
85
+ * (they are JSON envelopes, which typically shrink 5-10x), cutting
86
+ * DynamoDB write units and letting large responses fit the item budget;
87
+ * smaller bodies, or all of them with compression off, stay plain.
88
+ * Returns:
81
89
  *
82
90
  * - "stored": the record is in place and will replay.
83
91
  * - "too-large": even compressed, the body exceeds the item budget;
@@ -1,8 +1,8 @@
1
1
  import crypto from "crypto";
2
2
  import { DynamoDBClient, PutItemCommand, GetItemCommand, DeleteItemCommand, } from "@aws-sdk/client-dynamodb";
3
- import { brotliCompressText, brotliRestoreText } from "./LambderDdbCompression.js";
4
- /** Bodies at or above this size are stored Brotli-compressed; smaller ones stay plain. */
5
- const COMPRESS_MIN_BYTES = 1024;
3
+ import { brotliCompressText, brotliRestoreText, resolveCompressionOption, } from "./LambderDdbCompression.js";
4
+ /** Bodies of 1KB or more are stored Brotli-compressed by default; smaller ones stay plain. */
5
+ const COMPRESSION_DEFAULTS = { minBytes: 1024, quality: 5 };
6
6
  /**
7
7
  * Stored-body budget inside DynamoDB's 400KB item limit (headers, keys and
8
8
  * attributes need headroom). Applies to the bytes actually stored, so a
@@ -22,10 +22,11 @@ const MAX_STORED_BODY_BYTES = 350_000;
22
22
  * and loses the scope to a retry can no longer overwrite or delete the
23
23
  * retry's claim (both settle calls become silent no-ops instead).
24
24
  *
25
- * Stored bodies of 1KB or more are Brotli-compressed (same scheme as
26
- * LambderDdbCache): the bodies are JSON envelopes that typically shrink
27
- * 5-10x, which cuts DynamoDB write units and lets large responses fit the
28
- * item budget instead of skipping replay storage.
25
+ * Stored bodies are Brotli-compressed from 1KB by default (same scheme as
26
+ * LambderDdbCache, see the `compression` option): the bodies are JSON
27
+ * envelopes that typically shrink 5-10x, which cuts DynamoDB write units
28
+ * and lets large responses fit the item budget instead of skipping replay
29
+ * storage.
29
30
  *
30
31
  * Table shape: string hash key `pk`, string range key `sk`, TTL on
31
32
  * `expiresAt`. Items are prefixed `IDEM#` by default, so the table can be
@@ -35,17 +36,14 @@ const MAX_STORED_BODY_BYTES = 350_000;
35
36
  export class LambderDdbIdempotency {
36
37
  tableName;
37
38
  keyPrefix;
38
- compressionQuality;
39
+ compression;
39
40
  client;
40
41
  constructor(options) {
41
42
  if (!options.tableName.trim())
42
43
  throw new Error("tableName is required");
43
44
  this.tableName = options.tableName;
44
45
  this.keyPrefix = options.keyPrefix ?? "IDEM";
45
- this.compressionQuality = options.compressionQuality ?? 5;
46
- if (!Number.isInteger(this.compressionQuality) || this.compressionQuality < 0 || this.compressionQuality > 11) {
47
- throw new Error("compressionQuality must be an integer from 0 to 11");
48
- }
46
+ this.compression = resolveCompressionOption(options.compression, COMPRESSION_DEFAULTS);
49
47
  this.client = options.client ?? new DynamoDBClient(options.region ? { region: options.region } : {});
50
48
  }
51
49
  itemKey(scopeKey) {
@@ -142,10 +140,11 @@ export class LambderDdbIdempotency {
142
140
  }
143
141
  /**
144
142
  * Store the response for replays, overwriting the pending claim. Bodies
145
- * of COMPRESS_MIN_BYTES or more are stored Brotli-compressed (they are
146
- * JSON envelopes, which typically shrink 5-10x), cutting DynamoDB write
147
- * units and letting large responses fit the item budget; smaller bodies
148
- * stay plain. Returns:
143
+ * from the compression option's minBytes are stored Brotli-compressed
144
+ * (they are JSON envelopes, which typically shrink 5-10x), cutting
145
+ * DynamoDB write units and letting large responses fit the item budget;
146
+ * smaller bodies, or all of them with compression off, stay plain.
147
+ * Returns:
149
148
  *
150
149
  * - "stored": the record is in place and will replay.
151
150
  * - "too-large": even compressed, the body exceeds the item budget;
@@ -157,8 +156,8 @@ export class LambderDdbIdempotency {
157
156
  const nowSeconds = Math.floor(Date.now() / 1000);
158
157
  const rawBody = Buffer.from(body, "utf8");
159
158
  let bodyAttributes;
160
- if (rawBody.byteLength >= COMPRESS_MIN_BYTES) {
161
- const compressed = await brotliCompressText(rawBody, this.compressionQuality);
159
+ if (this.compression && rawBody.byteLength >= this.compression.minBytes) {
160
+ const compressed = await brotliCompressText(rawBody, this.compression.quality);
162
161
  if (compressed.byteLength > MAX_STORED_BODY_BYTES)
163
162
  return "too-large";
164
163
  // bodyBytes bounds and verifies decompression on read.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lambder",
3
- "version": "4.3.1",
3
+ "version": "4.3.2",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",