lambder 4.2.3 → 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 CHANGED
@@ -2,6 +2,10 @@
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
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.
@@ -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)`:
@@ -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
@@ -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)
package/dist/index.d.ts CHANGED
@@ -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";
@@ -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
- constructor({ tableName, tableRegion, partitionKey, sortKey, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, dataRefresh, }: {
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
- constructor({ tableName, tableRegion, partitionKey, sortKey, sessionSalt, enableSlidingExpiration = true, slidingWriteIntervalSeconds, dataRefresh, }) {
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
- async ddbPutItem(item) {
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)
@@ -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, brotliDecompressText } from "./LambderDdbCompression.js";
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 ./LambderDdbCompression.js.
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
- const output = await brotliDecompressText(cached.compressed, this.maxValueBytes);
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 output = await brotliDecompressText(compressed, this.maxValueBytes);
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, output.length, manifest.expiresAt);
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
- /** maxOutputLength bounds decompression so a corrupt record cannot balloon memory. */
3
- export declare const brotliDecompressText: (input: Buffer, maxOutputLength: number) => Promise<Buffer>;
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 (JSON), so TEXT mode;
4
- // zlib is loaded lazily through node-polyfills so these modules can sit in a
5
- // frontend bundle's import graph (via the package root) without breaking.
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
- /** maxOutputLength bounds decompression so a corrupt record cannot balloon memory. */
29
- export const brotliDecompressText = async (input, maxOutputLength) => {
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
- return new Promise((resolve, reject) => {
32
- zlib.brotliDecompress(input, { maxOutputLength }, (error, output) => {
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(output);
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, brotliDecompressText } from "./LambderDdbCompression.js";
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
- const declaredBytes = Number(item.bodyBytes?.N ?? 0);
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
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lambder",
3
- "version": "4.2.3",
3
+ "version": "4.3.1",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",