lambder 3.8.1 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/Readme.md +79 -14
  2. package/dist/{LambderCaller.d.ts → client/LambderCaller.d.ts} +30 -12
  3. package/dist/{LambderCaller.js → client/LambderCaller.js} +21 -12
  4. package/dist/{LambderMSW.d.ts → client/LambderMSW.d.ts} +1 -1
  5. package/dist/client.d.ts +16 -0
  6. package/dist/client.js +17 -0
  7. package/dist/{Lambder.d.ts → core/Lambder.d.ts} +45 -20
  8. package/dist/{Lambder.js → core/Lambder.js} +28 -13
  9. package/dist/{LambderContext.d.ts → core/LambderContext.d.ts} +9 -3
  10. package/dist/{LambderContext.js → core/LambderContext.js} +1 -0
  11. package/dist/{LambderPublicFiles.js → core/LambderPublicFiles.js} +1 -1
  12. package/dist/{LambderResponse.js → core/LambderResponse.js} +1 -1
  13. package/dist/{LambderResponseBuilder.d.ts → core/LambderResponseBuilder.d.ts} +3 -15
  14. package/dist/{LambderResponseBuilder.js → core/LambderResponseBuilder.js} +2 -2
  15. package/dist/{LambderTemplatingEngine.d.ts → core/LambderTemplatingEngine.d.ts} +1 -1
  16. package/dist/{LambderTemplatingEngine.js → core/LambderTemplatingEngine.js} +2 -2
  17. package/dist/index.d.ts +36 -35
  18. package/dist/index.js +21 -20
  19. package/dist/policies/LambderApiGuards.d.ts +221 -0
  20. package/dist/policies/LambderApiGuards.js +79 -0
  21. package/dist/policies/LambderApiIdempotency.d.ts +58 -0
  22. package/dist/policies/LambderApiIdempotency.js +215 -0
  23. package/dist/policies/LambderApiPolicies.d.ts +40 -0
  24. package/dist/policies/LambderApiPolicies.js +47 -0
  25. package/dist/policies/LambderApiRateLimits.d.ts +90 -0
  26. package/dist/policies/LambderApiRateLimits.js +77 -0
  27. package/dist/{LambderSessionController.d.ts → session/LambderSessionController.d.ts} +2 -1
  28. package/dist/{LambderSessionController.js → session/LambderSessionController.js} +16 -11
  29. package/dist/{LambderSessionManager.d.ts → session/LambderSessionManager.d.ts} +38 -4
  30. package/dist/{LambderSessionManager.js → session/LambderSessionManager.js} +49 -16
  31. package/dist/{LambderApiContract.d.ts → shared/LambderApiContract.d.ts} +14 -0
  32. package/dist/{LambderApiError.d.ts → shared/LambderApiError.d.ts} +1 -1
  33. package/dist/{LambderDdbCache.js → stores/LambderDdbCache.js} +7 -38
  34. package/dist/stores/LambderDdbCompression.d.ts +3 -0
  35. package/dist/stores/LambderDdbCompression.js +39 -0
  36. package/dist/{LambderDdbIdempotency.d.ts → stores/LambderDdbIdempotency.d.ts} +41 -12
  37. package/dist/{LambderDdbIdempotency.js → stores/LambderDdbIdempotency.js} +100 -11
  38. package/dist/{LambderDdbRateLimiter.js → stores/LambderDdbRateLimiter.js} +5 -1
  39. package/dist/testing.d.ts +9 -0
  40. package/dist/testing.js +8 -0
  41. package/package.json +19 -1
  42. package/dist/LambderApiPolicies.d.ts +0 -212
  43. package/dist/LambderApiPolicies.js +0 -228
  44. /package/dist/{LambderMSW.js → client/LambderMSW.js} +0 -0
  45. /package/dist/{LambderCors.d.ts → core/LambderCors.d.ts} +0 -0
  46. /package/dist/{LambderCors.js → core/LambderCors.js} +0 -0
  47. /package/dist/{LambderPublicFiles.d.ts → core/LambderPublicFiles.d.ts} +0 -0
  48. /package/dist/{LambderResolver.d.ts → core/LambderResolver.d.ts} +0 -0
  49. /package/dist/{LambderResolver.js → core/LambderResolver.js} +0 -0
  50. /package/dist/{LambderResponse.d.ts → core/LambderResponse.d.ts} +0 -0
  51. /package/dist/{LambderRouting.d.ts → core/LambderRouting.d.ts} +0 -0
  52. /package/dist/{LambderRouting.js → core/LambderRouting.js} +0 -0
  53. /package/dist/{LambderApiContract.js → shared/LambderApiContract.js} +0 -0
  54. /package/dist/{LambderApiError.js → shared/LambderApiError.js} +0 -0
  55. /package/dist/{LambderHtml.d.ts → shared/LambderHtml.d.ts} +0 -0
  56. /package/dist/{LambderHtml.js → shared/LambderHtml.js} +0 -0
  57. /package/dist/{LambderI18n.d.ts → shared/LambderI18n.d.ts} +0 -0
  58. /package/dist/{LambderI18n.js → shared/LambderI18n.js} +0 -0
  59. /package/dist/{node-polyfills.d.ts → shared/node-polyfills.d.ts} +0 -0
  60. /package/dist/{node-polyfills.js → shared/node-polyfills.js} +0 -0
  61. /package/dist/{LambderDdbCache.d.ts → stores/LambderDdbCache.d.ts} +0 -0
  62. /package/dist/{LambderDdbRateLimiter.d.ts → stores/LambderDdbRateLimiter.d.ts} +0 -0
@@ -13,6 +13,19 @@ export class LambderSessionDataRefreshError extends Error {
13
13
  this.name = "LambderSessionDataRefreshError";
14
14
  }
15
15
  }
16
+ /**
17
+ * Wraps DynamoDB failures during a session read so they stay distinguishable
18
+ * from "no session": fetchSessionIfExists() swallows missing or invalid
19
+ * sessions but rethrows this. Without the distinction a transient DynamoDB
20
+ * error would answer sessionExpired, and the caller would then clear the
21
+ * client's session cookies: an infra blip forcing a real logout.
22
+ */
23
+ export class LambderSessionReadError extends Error {
24
+ constructor(cause) {
25
+ super(`Session read failed: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
26
+ this.name = "LambderSessionReadError";
27
+ }
28
+ }
16
29
  export default class LambderSessionManager {
17
30
  tableName;
18
31
  sessionSalt;
@@ -38,6 +51,15 @@ export default class LambderSessionManager {
38
51
  .update(`${password}${this.sessionSalt}`)
39
52
  .digest("hex");
40
53
  }
54
+ /**
55
+ * At-rest hash for the bearer secrets (session sort-key secret, CSRF
56
+ * token). Fast unsalted sha256 is the right construction here: the
57
+ * inputs are 256-bit random values, so there is nothing to brute-force;
58
+ * hashing just ensures a leaked table read yields no usable cookies.
59
+ */
60
+ hashToken(value) {
61
+ return crypto.createHash("sha256").update(value).digest("hex");
62
+ }
41
63
  constantTimeCompare(a, b) {
42
64
  if (a.length !== b.length)
43
65
  return false;
@@ -90,22 +112,24 @@ export default class LambderSessionManager {
90
112
  }
91
113
  async createSession(sessionKey, data = {}, ttlInSeconds = 30 * 24 * 60 * 60, options) {
92
114
  const sessionKeyHash = this.sessionUserKeyHasher(sessionKey);
93
- const sessionSortKey = crypto.randomBytes(32).toString("hex");
94
- const sessionToken = `${sessionKeyHash}:${sessionSortKey}`;
115
+ // The sort-key SECRET goes to the client; only its hash becomes the
116
+ // DynamoDB range key, so the table never contains a usable token.
117
+ const sessionSortKeySecret = crypto.randomBytes(32).toString("hex");
118
+ const sessionToken = `${sessionKeyHash}:${sessionSortKeySecret}`;
95
119
  const csrfToken = crypto.randomBytes(32).toString("hex");
96
120
  const createdAt = Math.floor(Date.now() / 1000);
97
121
  const lastAccessedAt = createdAt;
98
122
  const expiresAt = Number(createdAt) + Number(ttlInSeconds);
99
123
  const session = {
100
124
  [this.partitionKey]: sessionKeyHash,
101
- [this.sortKey]: sessionSortKey,
102
- sessionToken, csrfToken,
125
+ [this.sortKey]: this.hashToken(sessionSortKeySecret),
126
+ csrfTokenHash: this.hashToken(csrfToken),
103
127
  sessionKey, data,
104
128
  createdAt, lastAccessedAt, expiresAt, ttlInSeconds,
105
129
  ...(this.dataRefresh ? { dataExpiresAt: options?.dataExpiresAt ?? (createdAt + this.dataRefresh.ttlSeconds) } : {}),
106
130
  };
107
131
  await this.ddbPutItem(session);
108
- return session;
132
+ return { session, sessionToken, csrfToken };
109
133
  }
110
134
  async updateSessionData(session, newData) {
111
135
  if (!session)
@@ -124,25 +148,28 @@ export default class LambderSessionManager {
124
148
  return session;
125
149
  }
126
150
  async getSession(sessionToken) {
127
- const [sessionKeyHash, sessionSortKey] = sessionToken.split(":");
128
- if (!sessionKeyHash || !sessionSortKey)
151
+ const [sessionKeyHash, sessionSortKeySecret] = sessionToken.split(":");
152
+ if (!sessionKeyHash || !sessionSortKeySecret)
129
153
  return null;
154
+ // A DynamoDB read failure propagates typed: null means "no such
155
+ // session", which callers translate to sessionExpired, and the caller
156
+ // then clears the client's session cookies. A transient infra error
157
+ // must surface as a 500, not force a logout.
130
158
  let session;
131
159
  try {
160
+ // The lookup itself proves possession of the raw secret: the
161
+ // range key is its hash, so only the true secret finds the item.
132
162
  session = await this.ddbGetItem({
133
163
  [this.partitionKey]: sessionKeyHash,
134
- [this.sortKey]: sessionSortKey
164
+ [this.sortKey]: this.hashToken(sessionSortKeySecret)
135
165
  });
136
166
  }
137
167
  catch (err) {
138
- return null;
168
+ throw new LambderSessionReadError(err);
139
169
  }
140
- // Use constant error response to prevent timing attacks
141
170
  if (!session)
142
171
  return null;
143
- if (!session.sessionToken || !this.constantTimeCompare(session.sessionToken, sessionToken))
144
- return null;
145
- if (!session.csrfToken)
172
+ if (!session.csrfTokenHash)
146
173
  return null;
147
174
  if (!session.sessionKey)
148
175
  return null;
@@ -233,9 +260,15 @@ export default class LambderSessionManager {
233
260
  return false;
234
261
  if (!sessionToken || typeof sessionToken !== "string")
235
262
  return false;
236
- if (!this.constantTimeCompare(session.sessionToken, sessionToken))
263
+ // Presented raw secrets are checked against the stored hashes.
264
+ const [sessionKeyHash, sessionSortKeySecret] = sessionToken.split(":");
265
+ if (!sessionKeyHash || !sessionSortKeySecret)
266
+ return false;
267
+ if (!this.constantTimeCompare(String(session[this.partitionKey] ?? ""), sessionKeyHash))
268
+ return false;
269
+ if (!this.constantTimeCompare(String(session[this.sortKey] ?? ""), this.hashToken(sessionSortKeySecret)))
237
270
  return false;
238
- if (!session.csrfToken)
271
+ if (!session.csrfTokenHash)
239
272
  return false;
240
273
  if (!session.sessionKey)
241
274
  return false;
@@ -246,7 +279,7 @@ export default class LambderSessionManager {
246
279
  if (!skipCsrfTokenCheck) {
247
280
  if (!csrfToken || typeof csrfToken !== "string")
248
281
  return false;
249
- if (!this.constantTimeCompare(session.csrfToken, csrfToken))
282
+ if (!this.constantTimeCompare(session.csrfTokenHash, this.hashToken(csrfToken)))
250
283
  return false;
251
284
  }
252
285
  return true;
@@ -12,6 +12,20 @@ export type ApiContractShape = Record<string, {
12
12
  /** Present when the API declares guardInput-mode guards: guard name -> value the client must send via options.guardInputs. */
13
13
  guardInputs?: any;
14
14
  }>;
15
+ /** Envelope flags/channels the server may set beside (or instead of) the payload. */
16
+ export type LambderApiResponseConfig = {
17
+ versionExpired?: boolean;
18
+ sessionExpired?: boolean;
19
+ notAuthorized?: boolean;
20
+ message?: any;
21
+ errorMessage?: any;
22
+ logList?: any[];
23
+ };
24
+ /** The API wire envelope both sides speak: res.api() emits it, LambderCaller parses it. */
25
+ export type LambderApiResponse<T> = LambderApiResponseConfig & {
26
+ apiVersion?: string | null;
27
+ payload?: T | null;
28
+ };
15
29
  /**
16
30
  * Helper type for merging new API into existing contract during chaining
17
31
  */
@@ -1,4 +1,4 @@
1
- import type { HttpStatusCode } from "./LambderResponse.js";
1
+ import type { HttpStatusCode } from "../core/LambderResponse.js";
2
2
  export type LambderApiErrorOptions = {
3
3
  /**
4
4
  * Structured, user-facing failure detail placed on the API envelope's
@@ -1,5 +1,6 @@
1
1
  import { BatchWriteItemCommand, DeleteItemCommand, DynamoDBClient, GetItemCommand, PutItemCommand, QueryCommand, } from "@aws-sdk/client-dynamodb";
2
- import { getCrypto, getZlib } from "./node-polyfills.js";
2
+ import { getCrypto } from "../shared/node-polyfills.js";
3
+ import { brotliCompressText, brotliDecompressText } from "./LambderDdbCompression.js";
3
4
  import { LRUCache } from "lru-cache";
4
5
  const DEFAULT_TTL_SECONDS = 365 * 24 * 60 * 60;
5
6
  const DEFAULT_CHUNK_BYTES = 350 * 1024;
@@ -12,46 +13,14 @@ const BATCH_WRITE_LIMIT = 25;
12
13
  const MAX_BATCH_RETRIES = 8;
13
14
  // Node builtins are loaded lazily through node-polyfills so this module can
14
15
  // sit in a frontend bundle's import graph (via the package root) without
15
- // breaking; using the cache at runtime still requires Node.
16
- const requireZlib = async () => {
17
- const zlib = await getZlib();
18
- if (!zlib)
19
- throw new Error("LambderDdbCache requires a Node.js environment.");
20
- return zlib;
21
- };
16
+ // breaking; using the cache at runtime still requires Node. Brotli helpers
17
+ // are shared with LambderDdbIdempotency via ./LambderDdbCompression.js.
22
18
  const requireCrypto = async () => {
23
19
  const crypto = await getCrypto();
24
20
  if (!crypto)
25
21
  throw new Error("LambderDdbCache requires a Node.js environment.");
26
22
  return crypto;
27
23
  };
28
- const compress = async (input, quality) => {
29
- const zlib = await requireZlib();
30
- return new Promise((resolve, reject) => {
31
- zlib.brotliCompress(input, {
32
- params: {
33
- [zlib.constants.BROTLI_PARAM_QUALITY]: quality,
34
- [zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_TEXT,
35
- },
36
- }, (error, output) => {
37
- if (error)
38
- reject(error);
39
- else
40
- resolve(output);
41
- });
42
- });
43
- };
44
- const decompress = async (input, maxOutputLength) => {
45
- const zlib = await requireZlib();
46
- return new Promise((resolve, reject) => {
47
- zlib.brotliDecompress(input, { maxOutputLength }, (error, output) => {
48
- if (error)
49
- reject(error);
50
- else
51
- resolve(output);
52
- });
53
- });
54
- };
55
24
  const sha256 = async (value) => {
56
25
  const crypto = await requireCrypto();
57
26
  return crypto.createHash("sha256").update(value).digest("hex");
@@ -127,7 +96,7 @@ export class LambderDdbCache {
127
96
  const nowSeconds = this.nowSeconds();
128
97
  if (cached && cached.expiresAt > nowSeconds) {
129
98
  try {
130
- const output = await decompress(cached.compressed, this.maxValueBytes);
99
+ const output = await brotliDecompressText(cached.compressed, this.maxValueBytes);
131
100
  if (output.length === cached.uncompressedBytes) {
132
101
  return JSON.parse(output.toString("utf8"));
133
102
  }
@@ -150,7 +119,7 @@ export class LambderDdbCache {
150
119
  if (await sha256(compressed) !== manifest.checksum) {
151
120
  throw new Error("compressed checksum does not match manifest");
152
121
  }
153
- const output = await decompress(compressed, this.maxValueBytes);
122
+ const output = await brotliDecompressText(compressed, this.maxValueBytes);
154
123
  if (output.length !== manifest.uncompressedBytes) {
155
124
  throw new Error("uncompressed byte length does not match manifest");
156
125
  }
@@ -186,7 +155,7 @@ export class LambderDdbCache {
186
155
  if (input.length > this.maxValueBytes) {
187
156
  throw new Error(`Cache value exceeds maxValueBytes (${input.length} > ${this.maxValueBytes})`);
188
157
  }
189
- const compressed = await compress(input, this.compressionQuality);
158
+ const compressed = await brotliCompressText(input, this.compressionQuality);
190
159
  if (compressed.length > this.maxValueBytes) {
191
160
  throw new Error(`Compressed cache value exceeds maxValueBytes (${compressed.length} > ${this.maxValueBytes})`);
192
161
  }
@@ -0,0 +1,3 @@
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>;
@@ -0,0 +1,39 @@
1
+ import { getZlib } from "../shared/node-polyfills.js";
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.
6
+ const requireZlib = async () => {
7
+ const zlib = await getZlib();
8
+ if (!zlib)
9
+ throw new Error("Lambder DDB stores require a Node.js environment.");
10
+ return zlib;
11
+ };
12
+ export const brotliCompressText = async (input, quality) => {
13
+ const zlib = await requireZlib();
14
+ return new Promise((resolve, reject) => {
15
+ zlib.brotliCompress(input, {
16
+ params: {
17
+ [zlib.constants.BROTLI_PARAM_QUALITY]: quality,
18
+ [zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_TEXT,
19
+ },
20
+ }, (error, output) => {
21
+ if (error)
22
+ reject(error);
23
+ else
24
+ resolve(output);
25
+ });
26
+ });
27
+ };
28
+ /** maxOutputLength bounds decompression so a corrupt record cannot balloon memory. */
29
+ export const brotliDecompressText = async (input, maxOutputLength) => {
30
+ const zlib = await requireZlib();
31
+ return new Promise((resolve, reject) => {
32
+ zlib.brotliDecompress(input, { maxOutputLength }, (error, output) => {
33
+ if (error)
34
+ reject(error);
35
+ else
36
+ resolve(output);
37
+ });
38
+ });
39
+ };
@@ -4,19 +4,24 @@ export interface LambderDdbIdempotencyOptions {
4
4
  region?: string;
5
5
  /** Partition key prefix, keeps records separated from other systems in a shared table. Default: "IDEM". */
6
6
  keyPrefix?: string;
7
+ /** Brotli quality (0-11) for stored bodies, like LambderDdbCache. Default: 5. */
8
+ compressionQuality?: number;
7
9
  client?: DynamoDBClient;
8
10
  }
11
+ export type LambderIdempotencyDoneRecord = {
12
+ statusCode: number;
13
+ /** Response headers stored with the record (normalized multi-value map). */
14
+ headers: Record<string, string[]>;
15
+ body: string;
16
+ };
9
17
  export type LambderIdempotencyBeginResult = {
10
18
  state: "new";
11
19
  ownerToken: string;
12
20
  } | {
13
21
  state: "pending";
14
- } | {
22
+ } | ({
15
23
  state: "done";
16
- statusCode: number;
17
- contentType: string | null;
18
- body: string;
19
- };
24
+ } & LambderIdempotencyDoneRecord);
20
25
  /**
21
26
  * DynamoDB-backed idempotency records: one item per (identity, api, key)
22
27
  * scope, claimed atomically with a conditional put. The first request claims
@@ -30,6 +35,11 @@ export type LambderIdempotencyBeginResult = {
30
35
  * and loses the scope to a retry can no longer overwrite or delete the
31
36
  * retry's claim (both settle calls become silent no-ops instead).
32
37
  *
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.
42
+ *
33
43
  * Table shape: string hash key `pk`, string range key `sk`, TTL on
34
44
  * `expiresAt`. Items are prefixed `IDEM#` by default, so the table can be
35
45
  * shared with LambderDdbRateLimiter (`RL#`) and LambderDdbCache (`CACHE#`)
@@ -38,9 +48,21 @@ export type LambderIdempotencyBeginResult = {
38
48
  export declare class LambderDdbIdempotency {
39
49
  readonly tableName: string;
40
50
  readonly keyPrefix: string;
51
+ private readonly compressionQuality;
41
52
  private readonly client;
42
53
  constructor(options: LambderDdbIdempotencyOptions);
43
54
  private itemKey;
55
+ /** Parse a stored item's response headers. */
56
+ private static readItemHeaders;
57
+ /** A stored item's response body: plain (`body`) or Brotli (`bodyBr` + `bodyBytes`). */
58
+ private static readItemBody;
59
+ /**
60
+ * Read the scope without claiming it: the stored response when a
61
+ * completed, unexpired record exists, null otherwise (absent, pending, or
62
+ * expired). Eventually-consistent read: a miss here only means the caller
63
+ * proceeds to begin(), whose read is authoritative.
64
+ */
65
+ peek(scopeKey: string): Promise<LambderIdempotencyDoneRecord | null>;
44
66
  /**
45
67
  * Claim the scope. "new" means this request now owns it (proven by the
46
68
  * returned ownerToken) and must call complete() or abandon(); "pending"
@@ -51,17 +73,24 @@ export declare class LambderDdbIdempotency {
51
73
  pendingTtlSeconds: number;
52
74
  }): Promise<LambderIdempotencyBeginResult>;
53
75
  /**
54
- * Store the response for replays, overwriting the pending claim. Requires
55
- * still holding the claim: returns false (storing nothing) when the
56
- * ownerToken no longer matches, i.e. the claim expired and a retry took
57
- * the scope over.
76
+ * 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:
81
+ *
82
+ * - "stored": the record is in place and will replay.
83
+ * - "too-large": even compressed, the body exceeds the item budget;
84
+ * nothing was written and the caller should release the claim.
85
+ * - "lost": the ownerToken no longer matches, i.e. the claim expired and
86
+ * a retry took the scope over; nothing was written.
58
87
  */
59
- complete(scopeKey: string, ownerToken: string, { statusCode, contentType, body, ttlSeconds }: {
88
+ complete(scopeKey: string, ownerToken: string, { statusCode, headers, body, ttlSeconds }: {
60
89
  statusCode: number;
61
- contentType: string | null;
90
+ headers: Record<string, string[]>;
62
91
  body: string;
63
92
  ttlSeconds: number;
64
- }): Promise<boolean>;
93
+ }): Promise<"stored" | "too-large" | "lost">;
65
94
  /**
66
95
  * Release the claim without storing a response (crash, uncacheable
67
96
  * response), so a retry can execute. Conditional on still holding the
@@ -1,5 +1,14 @@
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";
4
+ /** Bodies at or above this size are stored Brotli-compressed; smaller ones stay plain. */
5
+ const COMPRESS_MIN_BYTES = 1024;
6
+ /**
7
+ * Stored-body budget inside DynamoDB's 400KB item limit (headers, keys and
8
+ * attributes need headroom). Applies to the bytes actually stored, so a
9
+ * large compressible response (JSON usually shrinks 5-10x) still replays.
10
+ */
11
+ const MAX_STORED_BODY_BYTES = 350_000;
3
12
  /**
4
13
  * DynamoDB-backed idempotency records: one item per (identity, api, key)
5
14
  * scope, claimed atomically with a conditional put. The first request claims
@@ -13,6 +22,11 @@ import { DynamoDBClient, PutItemCommand, GetItemCommand, DeleteItemCommand, } fr
13
22
  * and loses the scope to a retry can no longer overwrite or delete the
14
23
  * retry's claim (both settle calls become silent no-ops instead).
15
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.
29
+ *
16
30
  * Table shape: string hash key `pk`, string range key `sk`, TTL on
17
31
  * `expiresAt`. Items are prefixed `IDEM#` by default, so the table can be
18
32
  * shared with LambderDdbRateLimiter (`RL#`) and LambderDdbCache (`CACHE#`)
@@ -21,17 +35,73 @@ import { DynamoDBClient, PutItemCommand, GetItemCommand, DeleteItemCommand, } fr
21
35
  export class LambderDdbIdempotency {
22
36
  tableName;
23
37
  keyPrefix;
38
+ compressionQuality;
24
39
  client;
25
40
  constructor(options) {
26
41
  if (!options.tableName.trim())
27
42
  throw new Error("tableName is required");
28
43
  this.tableName = options.tableName;
29
44
  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
+ }
30
49
  this.client = options.client ?? new DynamoDBClient(options.region ? { region: options.region } : {});
31
50
  }
32
51
  itemKey(scopeKey) {
33
52
  return { pk: { S: `${this.keyPrefix}#${scopeKey}` }, sk: { S: "idem" } };
34
53
  }
54
+ /** Parse a stored item's response headers. */
55
+ static readItemHeaders(item) {
56
+ const raw = item.headersJson?.S;
57
+ if (!raw)
58
+ return {};
59
+ try {
60
+ const parsed = JSON.parse(raw);
61
+ if (parsed && typeof parsed === "object")
62
+ return parsed;
63
+ }
64
+ catch { /* corrupt record: replay with no headers rather than fail */ }
65
+ return {};
66
+ }
67
+ /** A stored item's response body: plain (`body`) or Brotli (`bodyBr` + `bodyBytes`). */
68
+ static async readItemBody(item) {
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
+ }
80
+ return item.body?.S ?? "";
81
+ }
82
+ /**
83
+ * Read the scope without claiming it: the stored response when a
84
+ * completed, unexpired record exists, null otherwise (absent, pending, or
85
+ * expired). Eventually-consistent read: a miss here only means the caller
86
+ * proceeds to begin(), whose read is authoritative.
87
+ */
88
+ async peek(scopeKey) {
89
+ const existing = await this.client.send(new GetItemCommand({
90
+ TableName: this.tableName,
91
+ Key: this.itemKey(scopeKey),
92
+ }));
93
+ const item = existing.Item;
94
+ if (!item || item.state?.S !== "done")
95
+ return null;
96
+ const nowSeconds = Math.floor(Date.now() / 1000);
97
+ if (Number(item.expiresAt?.N ?? 0) <= nowSeconds)
98
+ return null;
99
+ return {
100
+ statusCode: Number(item.statusCode?.N ?? 200),
101
+ headers: LambderDdbIdempotency.readItemHeaders(item),
102
+ body: await LambderDdbIdempotency.readItemBody(item),
103
+ };
104
+ }
35
105
  /**
36
106
  * Claim the scope. "new" means this request now owns it (proven by the
37
107
  * returned ownerToken) and must call complete() or abandon(); "pending"
@@ -72,20 +142,39 @@ export class LambderDdbIdempotency {
72
142
  return {
73
143
  state: "done",
74
144
  statusCode: Number(item.statusCode?.N ?? 200),
75
- contentType: item.contentType?.S ?? null,
76
- body: item.body?.S ?? "",
145
+ headers: LambderDdbIdempotency.readItemHeaders(item),
146
+ body: await LambderDdbIdempotency.readItemBody(item),
77
147
  };
78
148
  }
79
149
  return { state: "pending" };
80
150
  }
81
151
  /**
82
- * Store the response for replays, overwriting the pending claim. Requires
83
- * still holding the claim: returns false (storing nothing) when the
84
- * ownerToken no longer matches, i.e. the claim expired and a retry took
85
- * the scope over.
152
+ * Store the response for replays, overwriting the pending claim. Bodies
153
+ * of COMPRESS_MIN_BYTES or more are stored Brotli-compressed (they are
154
+ * JSON envelopes, which typically shrink 5-10x), cutting DynamoDB write
155
+ * units and letting large responses fit the item budget; smaller bodies
156
+ * stay plain. Returns:
157
+ *
158
+ * - "stored": the record is in place and will replay.
159
+ * - "too-large": even compressed, the body exceeds the item budget;
160
+ * nothing was written and the caller should release the claim.
161
+ * - "lost": the ownerToken no longer matches, i.e. the claim expired and
162
+ * a retry took the scope over; nothing was written.
86
163
  */
87
- async complete(scopeKey, ownerToken, { statusCode, contentType, body, ttlSeconds }) {
164
+ async complete(scopeKey, ownerToken, { statusCode, headers, body, ttlSeconds }) {
88
165
  const nowSeconds = Math.floor(Date.now() / 1000);
166
+ const rawBody = Buffer.from(body, "utf8");
167
+ let bodyAttributes;
168
+ if (rawBody.byteLength >= COMPRESS_MIN_BYTES) {
169
+ const compressed = await brotliCompressText(rawBody, this.compressionQuality);
170
+ if (compressed.byteLength > MAX_STORED_BODY_BYTES)
171
+ return "too-large";
172
+ // bodyBytes bounds and verifies decompression on read.
173
+ bodyAttributes = { bodyBr: { B: compressed }, bodyBytes: { N: String(rawBody.byteLength) } };
174
+ }
175
+ else {
176
+ bodyAttributes = { body: { S: body } };
177
+ }
89
178
  try {
90
179
  await this.client.send(new PutItemCommand({
91
180
  TableName: this.tableName,
@@ -94,19 +183,19 @@ export class LambderDdbIdempotency {
94
183
  state: { S: "done" },
95
184
  ownerToken: { S: ownerToken },
96
185
  statusCode: { N: String(statusCode) },
97
- ...(contentType ? { contentType: { S: contentType } } : {}),
98
- body: { S: body },
186
+ headersJson: { S: JSON.stringify(headers) },
187
+ ...bodyAttributes,
99
188
  expiresAt: { N: String(nowSeconds + ttlSeconds) },
100
189
  },
101
190
  ConditionExpression: "ownerToken = :owner",
102
191
  ExpressionAttributeValues: { ":owner": { S: ownerToken } },
103
192
  }));
104
- return true;
193
+ return "stored";
105
194
  }
106
195
  catch (error) {
107
196
  if (error.name !== "ConditionalCheckFailedException")
108
197
  throw error;
109
- return false;
198
+ return "lost";
110
199
  }
111
200
  }
112
201
  /**
@@ -81,8 +81,12 @@ export class LambderDdbRateLimiter {
81
81
  catch (error) {
82
82
  if (error.name === "ConditionalCheckFailedException")
83
83
  return true;
84
- if (this.failOpen)
84
+ if (this.failOpen) {
85
+ // Failing open swallows the error from the caller's view, so
86
+ // keep the infra failure visible in the logs.
87
+ console.error(`LambderDdbRateLimiter: DynamoDB error while counting "${trackerKey}", allowing the request (failOpen).`, error);
85
88
  return false;
89
+ }
86
90
  throw error;
87
91
  }
88
92
  }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Testing entry point (`import ... from "lambder/testing"`).
3
+ *
4
+ * Mock tooling that neither the server nor the production client bundle
5
+ * should carry: the MSW adapter that serves an app's typed API contract
6
+ * from in-browser mock handlers during development and tests.
7
+ */
8
+ export { default as LambderMSW } from "./client/LambderMSW.js";
9
+ export type { LambderMswModule } from "./client/LambderMSW.js";
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Testing entry point (`import ... from "lambder/testing"`).
3
+ *
4
+ * Mock tooling that neither the server nor the production client bundle
5
+ * should carry: the MSW adapter that serves an app's typed API contract
6
+ * from in-browser mock handlers during development and tests.
7
+ */
8
+ export { default as LambderMSW } from "./client/LambderMSW.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lambder",
3
- "version": "3.8.1",
3
+ "version": "4.0.1",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -9,6 +9,24 @@
9
9
  ".": {
10
10
  "types": "./dist/index.d.ts",
11
11
  "default": "./dist/index.js"
12
+ },
13
+ "./client": {
14
+ "types": "./dist/client.d.ts",
15
+ "default": "./dist/client.js"
16
+ },
17
+ "./testing": {
18
+ "types": "./dist/testing.d.ts",
19
+ "default": "./dist/testing.js"
20
+ }
21
+ },
22
+ "typesVersions": {
23
+ "*": {
24
+ "client": [
25
+ "./dist/client.d.ts"
26
+ ],
27
+ "testing": [
28
+ "./dist/testing.d.ts"
29
+ ]
12
30
  }
13
31
  },
14
32
  "browser": {