lambder 6.0.1 → 7.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 (195) hide show
  1. package/CHANGELOG.md +2316 -0
  2. package/README.md +60 -33
  3. package/dist/api/LambderApiAnswer.d.ts +40 -0
  4. package/dist/api/LambderApiAnswer.js +19 -0
  5. package/dist/api/LambderApiCallContext.d.ts +38 -0
  6. package/dist/api/LambderApiCallContext.js +13 -0
  7. package/dist/api/LambderApiDefinition.d.ts +18 -0
  8. package/dist/api/LambderApiDefinition.js +1 -0
  9. package/dist/api/LambderApiEnvelope.d.ts +67 -0
  10. package/dist/api/LambderApiEnvelope.js +180 -0
  11. package/dist/api/LambderApiGuards.d.ts +302 -0
  12. package/dist/api/LambderApiGuards.js +134 -0
  13. package/dist/api/LambderApiIdempotency.d.ts +122 -0
  14. package/dist/api/LambderApiIdempotency.js +330 -0
  15. package/dist/api/LambderApiPipeline.d.ts +134 -0
  16. package/dist/api/LambderApiPipeline.js +221 -0
  17. package/dist/api/LambderApiPolicyEngine.d.ts +36 -0
  18. package/dist/api/LambderApiPolicyEngine.js +77 -0
  19. package/dist/api/LambderApiRateLimits.d.ts +206 -0
  20. package/dist/api/LambderApiRateLimits.js +239 -0
  21. package/dist/api/LambderApiRequest.d.ts +101 -0
  22. package/dist/api/LambderApiRequest.js +129 -0
  23. package/dist/api/LambderApiValidationRefusal.d.ts +32 -0
  24. package/dist/api/LambderApiValidationRefusal.js +40 -0
  25. package/dist/client/LambderCaller.d.ts +62 -55
  26. package/dist/client/LambderCaller.js +147 -90
  27. package/dist/client/lambderFetchTransport.d.ts +9 -0
  28. package/dist/client/lambderFetchTransport.js +71 -0
  29. package/dist/client.d.ts +20 -10
  30. package/dist/client.js +11 -5
  31. package/dist/core/Lambder.d.ts +117 -253
  32. package/dist/core/Lambder.js +374 -341
  33. package/dist/core/LambderContext.d.ts +54 -44
  34. package/dist/core/LambderContext.js +41 -110
  35. package/dist/core/LambderCreateOptions.d.ts +285 -0
  36. package/dist/core/LambderCreateOptions.js +44 -0
  37. package/dist/core/LambderFiles.d.ts +1 -45
  38. package/dist/core/LambderFiles.js +18 -38
  39. package/dist/core/LambderIndexHtml.d.ts +37 -0
  40. package/dist/core/LambderIndexHtml.js +87 -0
  41. package/dist/core/LambderPolicyBuilders.d.ts +17 -0
  42. package/dist/core/LambderPolicyBuilders.js +16 -0
  43. package/dist/core/LambderPublicFiles.d.ts +5 -2
  44. package/dist/core/LambderPublicFiles.js +7 -2
  45. package/dist/core/LambderResolver.d.ts +8 -6
  46. package/dist/core/LambderResponse.d.ts +29 -11
  47. package/dist/core/LambderResponse.js +96 -49
  48. package/dist/core/LambderResponseBuilder.d.ts +18 -14
  49. package/dist/core/LambderResponseBuilder.js +19 -25
  50. package/dist/core/LambderRouting.d.ts +18 -7
  51. package/dist/core/LambderRouting.js +17 -7
  52. package/dist/core/LambderTemplatingEngine.d.ts +0 -62
  53. package/dist/core/LambderTemplatingEngine.js +7 -3
  54. package/dist/index.d.ts +85 -32
  55. package/dist/index.js +44 -16
  56. package/dist/invoke/LambderInvokeCaller.d.ts +46 -139
  57. package/dist/invoke/LambderInvokeCaller.js +140 -335
  58. package/dist/invoke/LambderInvokeOutcome.d.ts +165 -0
  59. package/dist/invoke/LambderInvokeOutcome.js +129 -0
  60. package/dist/invoke/LambderLambdaEvent.d.ts +81 -0
  61. package/dist/invoke/LambderLambdaEvent.js +187 -0
  62. package/dist/invoke/lambderHandlerTransport.d.ts +36 -0
  63. package/dist/invoke/lambderHandlerTransport.js +89 -0
  64. package/dist/mock/LambderMockApp.d.ts +352 -0
  65. package/dist/mock/LambderMockApp.js +815 -0
  66. package/dist/mock/LambderMockBrowserCookies.d.ts +55 -0
  67. package/dist/mock/LambderMockBrowserCookies.js +76 -0
  68. package/dist/mock/LambderMockCallRecorder.d.ts +85 -0
  69. package/dist/mock/LambderMockCallRecorder.js +183 -0
  70. package/dist/mock/LambderMockCreateOptions.d.ts +161 -0
  71. package/dist/mock/LambderMockCreateOptions.js +9 -0
  72. package/dist/mock/LambderMockEntryRegistry.d.ts +52 -0
  73. package/dist/mock/LambderMockEntryRegistry.js +126 -0
  74. package/dist/mock/LambderMockFailureInjector.d.ts +60 -0
  75. package/dist/mock/LambderMockFailureInjector.js +138 -0
  76. package/dist/mock/LambderMockTypes.d.ts +421 -0
  77. package/dist/mock/LambderMockTypes.js +8 -0
  78. package/dist/mock/lambderMockConsoleLogger.d.ts +16 -0
  79. package/dist/mock/lambderMockConsoleLogger.js +35 -0
  80. package/dist/mock/lambderMockInvokeTransport.d.ts +50 -0
  81. package/dist/mock/lambderMockInvokeTransport.js +52 -0
  82. package/dist/mock/lambderMockMswHandler.d.ts +99 -0
  83. package/dist/mock/lambderMockMswHandler.js +126 -0
  84. package/dist/mock.d.ts +34 -0
  85. package/dist/mock.js +27 -0
  86. package/dist/session/LambderSessionController.d.ts +199 -30
  87. package/dist/session/LambderSessionController.js +396 -82
  88. package/dist/session/LambderSessionCrypto.d.ts +66 -0
  89. package/dist/session/LambderSessionCrypto.js +101 -0
  90. package/dist/session/LambderSessionManager.d.ts +118 -80
  91. package/dist/session/LambderSessionManager.js +212 -184
  92. package/dist/shared/LambderI18n.d.ts +6 -6
  93. package/dist/shared/LambderI18n.js +1 -1
  94. package/dist/shared/contracts/LambderFileSource.d.ts +33 -0
  95. package/dist/shared/contracts/LambderFileSource.js +19 -0
  96. package/dist/shared/contracts/LambderIdempotencyStore.d.ts +66 -0
  97. package/dist/shared/contracts/LambderIdempotencyStore.js +12 -0
  98. package/dist/shared/contracts/LambderRateLimiter.d.ts +71 -0
  99. package/dist/shared/contracts/LambderRateLimiter.js +24 -0
  100. package/dist/shared/contracts/LambderSessionStore.d.ts +72 -0
  101. package/dist/shared/contracts/LambderSessionStore.js +13 -0
  102. package/dist/shared/transport/LambderApiTransport.d.ts +139 -0
  103. package/dist/shared/transport/LambderApiTransport.js +65 -0
  104. package/dist/shared/transport/LambderCookieJar.d.ts +121 -0
  105. package/dist/shared/transport/LambderCookieJar.js +246 -0
  106. package/dist/shared/transport/lambderCookieJarTransport.d.ts +30 -0
  107. package/dist/shared/transport/lambderCookieJarTransport.js +60 -0
  108. package/dist/shared/util/LambderBase64.d.ts +10 -0
  109. package/dist/shared/util/LambderBase64.js +27 -0
  110. package/dist/shared/util/LambderCallAbort.d.ts +62 -0
  111. package/dist/shared/util/LambderCallAbort.js +80 -0
  112. package/dist/shared/util/LambderClientIp.d.ts +32 -0
  113. package/dist/shared/util/LambderClientIp.js +56 -0
  114. package/dist/shared/util/LambderExpiringMap.d.ts +119 -0
  115. package/dist/shared/util/LambderExpiringMap.js +217 -0
  116. package/dist/shared/util/LambderKeyFields.d.ts +32 -0
  117. package/dist/shared/util/LambderKeyFields.js +34 -0
  118. package/dist/shared/util/LambderNodeModules.d.ts +9 -0
  119. package/dist/shared/util/LambderNodeModules.js +39 -0
  120. package/dist/shared/util/LambderOptionChecks.d.ts +17 -0
  121. package/dist/shared/util/LambderOptionChecks.js +33 -0
  122. package/dist/shared/util/LambderResponseBrand.d.ts +20 -0
  123. package/dist/shared/util/LambderResponseBrand.js +18 -0
  124. package/dist/shared/util/LambderTextDigest.d.ts +17 -0
  125. package/dist/shared/util/LambderTextDigest.js +34 -0
  126. package/dist/shared/util/LambderTypeUtilities.d.ts +33 -0
  127. package/dist/shared/util/LambderTypeUtilities.js +8 -0
  128. package/dist/shared/wire/LambderAnswerHeaders.d.ts +60 -0
  129. package/dist/shared/wire/LambderAnswerHeaders.js +94 -0
  130. package/dist/shared/wire/LambderApiContract.d.ts +129 -0
  131. package/dist/shared/wire/LambderApiOptionValues.d.ts +39 -0
  132. package/dist/shared/wire/LambderApiOptionValues.js +11 -0
  133. package/dist/shared/wire/LambderApiOutcome.d.ts +128 -0
  134. package/dist/shared/{LambderApiOutcome.js → wire/LambderApiOutcome.js} +16 -9
  135. package/dist/shared/{LambderApiError.d.ts → wire/LambderApiRefusal.d.ts} +48 -26
  136. package/dist/shared/{LambderApiError.js → wire/LambderApiRefusal.js} +13 -11
  137. package/dist/shared/wire/LambderCallOptions.d.ts +171 -0
  138. package/dist/shared/wire/LambderCallOptions.js +17 -0
  139. package/dist/shared/{LambderCompressionCodec.d.ts → wire/LambderCompressionCodec.d.ts} +10 -6
  140. package/dist/shared/{LambderCompressionCodec.js → wire/LambderCompressionCodec.js} +67 -23
  141. package/dist/shared/{LambderCompressionOption.d.ts → wire/LambderCompressionOption.d.ts} +1 -1
  142. package/dist/shared/{LambderCompressionOption.js → wire/LambderCompressionOption.js} +3 -4
  143. package/dist/shared/{LambderCrashDetail.d.ts → wire/LambderCrashDetail.d.ts} +10 -0
  144. package/dist/shared/{LambderCrashDetail.js → wire/LambderCrashDetail.js} +30 -0
  145. package/dist/shared/wire/LambderHttpStatus.d.ts +12 -0
  146. package/dist/shared/wire/LambderHttpStatus.js +1 -0
  147. package/dist/shared/{LambderRequestPayload.d.ts → wire/LambderRequestPayload.d.ts} +25 -17
  148. package/dist/shared/{LambderRequestPayload.js → wire/LambderRequestPayload.js} +29 -52
  149. package/dist/shared/wire/LambderSessionCookieNames.d.ts +9 -0
  150. package/dist/shared/wire/LambderSessionCookieNames.js +9 -0
  151. package/dist/stores/LambderDdbCache.d.ts +12 -9
  152. package/dist/stores/LambderDdbCache.js +56 -47
  153. package/dist/stores/{LambderDdbIdempotency.d.ts → LambderDdbIdempotencyStore.d.ts} +41 -31
  154. package/dist/stores/LambderDdbIdempotencyStore.js +319 -0
  155. package/dist/stores/LambderDdbRateLimiter.d.ts +30 -49
  156. package/dist/stores/LambderDdbRateLimiter.js +47 -45
  157. package/dist/stores/LambderDdbSdk.d.ts +83 -6
  158. package/dist/stores/LambderDdbSdk.js +83 -2
  159. package/dist/stores/LambderDdbSessionStore.d.ts +65 -0
  160. package/dist/stores/LambderDdbSessionStore.js +161 -0
  161. package/dist/stores/LambderHttpFileSource.d.ts +1 -1
  162. package/dist/stores/LambderHttpFileSource.js +10 -1
  163. package/dist/stores/LambderLocalFileSource.d.ts +15 -0
  164. package/dist/stores/LambderLocalFileSource.js +28 -0
  165. package/dist/stores/LambderMemoryIdempotencyStore.d.ts +63 -0
  166. package/dist/stores/LambderMemoryIdempotencyStore.js +113 -0
  167. package/dist/stores/LambderMemoryRateLimiter.d.ts +34 -0
  168. package/dist/stores/LambderMemoryRateLimiter.js +64 -0
  169. package/dist/stores/LambderMemorySessionStore.d.ts +48 -0
  170. package/dist/stores/LambderMemorySessionStore.js +74 -0
  171. package/dist/stores/LambderS3FileSource.d.ts +1 -1
  172. package/dist/stores/LambderS3FileSource.js +1 -1
  173. package/package.json +26 -24
  174. package/dist/client/LambderMSW.d.ts +0 -69
  175. package/dist/client/LambderMSW.js +0 -121
  176. package/dist/policies/LambderApiGuards.d.ts +0 -256
  177. package/dist/policies/LambderApiGuards.js +0 -94
  178. package/dist/policies/LambderApiIdempotency.d.ts +0 -58
  179. package/dist/policies/LambderApiIdempotency.js +0 -219
  180. package/dist/policies/LambderApiPolicies.d.ts +0 -42
  181. package/dist/policies/LambderApiPolicies.js +0 -52
  182. package/dist/policies/LambderApiRateLimits.d.ts +0 -132
  183. package/dist/policies/LambderApiRateLimits.js +0 -119
  184. package/dist/shared/LambderApiContract.d.ts +0 -57
  185. package/dist/shared/LambderApiOutcome.d.ts +0 -69
  186. package/dist/shared/LambderCallOptions.d.ts +0 -71
  187. package/dist/shared/LambderCallOptions.js +0 -16
  188. package/dist/shared/node-polyfills.d.ts +0 -4
  189. package/dist/shared/node-polyfills.js +0 -58
  190. package/dist/stores/LambderDdbIdempotency.js +0 -229
  191. package/dist/testing.d.ts +0 -9
  192. package/dist/testing.js +0 -8
  193. /package/dist/shared/{LambderApiContract.js → wire/LambderApiContract.js} +0 -0
  194. /package/dist/{core → shared/wire}/LambderCookie.d.ts +0 -0
  195. /package/dist/{core → shared/wire}/LambderCookie.js +0 -0
@@ -1,7 +1,9 @@
1
1
  import type { DynamoDBClient } from "@aws-sdk/client-dynamodb";
2
- import { type LambderCompressionOption } from "../shared/LambderCompressionOption.js";
3
- export interface LambderDdbIdempotencyOptions {
2
+ import type { LambderIdempotencyStore, LambderIdempotencyDoneRecord, LambderIdempotencyBeginResult } from "../shared/contracts/LambderIdempotencyStore.js";
3
+ import { type LambderCompressionOption } from "../shared/wire/LambderCompressionOption.js";
4
+ export interface LambderDdbIdempotencyStoreOptions {
4
5
  tableName: string;
6
+ /** Region the client is created for on first use; the SDK's default chain otherwise. */
5
7
  region?: string;
6
8
  /** Partition key prefix, keeps records separated from other systems in a shared table. Default: "IDEM". */
7
9
  keyPrefix?: string;
@@ -13,21 +15,13 @@ export interface LambderDdbIdempotencyOptions {
13
15
  */
14
16
  compression?: LambderCompressionOption;
15
17
  client?: DynamoDBClient;
18
+ /**
19
+ * The clock claims and records are expired against, injectable the way
20
+ * LambderMemoryIdempotencyStore's is, so the conformance suite can expire a
21
+ * claim in both implementations through one clock.
22
+ */
23
+ now?: () => number;
16
24
  }
17
- export type LambderIdempotencyDoneRecord = {
18
- statusCode: number;
19
- /** Response headers stored with the record (normalized multi-value map). */
20
- headers: Record<string, string[]>;
21
- body: string;
22
- };
23
- export type LambderIdempotencyBeginResult = {
24
- state: "new";
25
- ownerToken: string;
26
- } | {
27
- state: "pending";
28
- } | ({
29
- state: "done";
30
- } & LambderIdempotencyDoneRecord);
31
25
  /**
32
26
  * DynamoDB-backed idempotency records: one item per (identity, api, key)
33
27
  * scope, claimed atomically with a conditional put. The first request claims
@@ -39,7 +33,10 @@ export type LambderIdempotencyBeginResult = {
39
33
  * Every claim carries a random ownerToken, and complete()/abandon() are
40
34
  * conditional on still holding it: an original that outlives its pending TTL
41
35
  * and loses the scope to a retry can no longer overwrite or delete the
42
- * retry's claim (both settle calls become silent no-ops instead).
36
+ * retry's claim (both settle calls become silent no-ops instead). complete()
37
+ * also requires the claim to be unexpired, so an owner whose claim ran out
38
+ * reports "lost" whether or not TTL deletion has caught up with it, which is
39
+ * what the memory store has always reported.
43
40
  *
44
41
  * Stored bodies are Brotli-compressed from 1KB by default (same scheme as
45
42
  * LambderDdbCache, see the `compression` option): the bodies are JSON
@@ -47,27 +44,43 @@ export type LambderIdempotencyBeginResult = {
47
44
  * and lets large responses fit the item budget instead of skipping replay
48
45
  * storage.
49
46
  *
47
+ * The scope key carries caller data (the client's idempotency key, and an
48
+ * identity when one is configured), so a scope whose partition key would pass
49
+ * DynamoDB's 2048-byte limit is refused here with an error that names the
50
+ * limit, rather than reaching the table and coming back as a
51
+ * ValidationException that reads as "the table is broken".
52
+ *
50
53
  * Table shape: string hash key `pk`, string range key `sk`, TTL on
51
54
  * `expiresAt`. Items are prefixed `IDEM#` by default, so the table can be
52
55
  * shared with LambderDdbRateLimiter (`RL#`) and LambderDdbCache (`CACHE#`)
53
56
  * without key collisions.
54
57
  */
55
- export declare class LambderDdbIdempotency {
58
+ export declare class LambderDdbIdempotencyStore implements LambderIdempotencyStore {
56
59
  readonly tableName: string;
57
60
  readonly keyPrefix: string;
58
61
  private readonly compression;
59
- /** The client given at creation, or one created from `region` on first use; the SDK arrives with it. */
60
- private readonly providedClient;
61
- private readonly region;
62
- private readyPromise;
63
- constructor(options: LambderDdbIdempotencyOptions);
64
62
  /** The SDK and the client, loaded and created the first time the table is touched (see LambderDdbSdk). */
65
- private ready;
63
+ private readonly ready;
64
+ private readonly now;
65
+ constructor(options: LambderDdbIdempotencyStoreOptions);
66
+ private nowSeconds;
66
67
  private itemKey;
67
- /** Parse a stored item's response headers. */
68
+ /**
69
+ * A stored item's response headers: the multi-value map the answer
70
+ * replays. Every entry is checked to BE one, because the engine hands
71
+ * what comes back to the response builder, which would ship a number or a
72
+ * bare string as a header value.
73
+ */
68
74
  private static readItemHeaders;
69
- /** A stored item's response body: plain (`body`) or Brotli (`bodyBr` + `bodyBytes`). */
75
+ /**
76
+ * A stored item's response body: plain (`body`) or Brotli (`bodyBr` +
77
+ * `bodyBytes`). The stored length is the decompression budget, so it is
78
+ * checked here the way the headers are: a record declaring more than this
79
+ * store ever writes is unusable, not an invitation to allocate it.
80
+ */
70
81
  private static readItemBody;
82
+ /** A stored answer as the engine reads it, with every field of the record checked rather than cast. */
83
+ private static answerOf;
71
84
  /**
72
85
  * Read the scope without claiming it: the stored response when a
73
86
  * completed, unexpired record exists, null otherwise (absent, pending, or
@@ -98,10 +111,7 @@ export declare class LambderDdbIdempotency {
98
111
  * - "lost": the ownerToken no longer matches, i.e. the claim expired and
99
112
  * a retry took the scope over; nothing was written.
100
113
  */
101
- complete(scopeKey: string, ownerToken: string, { statusCode, headers, body, ttlSeconds }: {
102
- statusCode: number;
103
- headers: Record<string, string[]>;
104
- body: string;
114
+ complete(scopeKey: string, ownerToken: string, { statusCode, headers, body, ttlSeconds }: LambderIdempotencyDoneRecord & {
105
115
  ttlSeconds: number;
106
116
  }): Promise<"stored" | "too-large" | "lost">;
107
117
  /**
@@ -111,4 +121,4 @@ export declare class LambderDdbIdempotency {
111
121
  */
112
122
  abandon(scopeKey: string, ownerToken: string): Promise<void>;
113
123
  }
114
- export default LambderDdbIdempotency;
124
+ export default LambderDdbIdempotencyStore;
@@ -0,0 +1,319 @@
1
+ import { assertPartitionKeyFits, createDynamoClientLoader, isConditionalCheckFailure, } from "./LambderDdbSdk.js";
2
+ import { getCrypto } from "../shared/util/LambderNodeModules.js";
3
+ import { compressText, restoreText } from "../shared/wire/LambderCompressionCodec.js";
4
+ import { resolveCompressionOption, } from "../shared/wire/LambderCompressionOption.js";
5
+ /** Bodies of 1KB or more are stored Brotli-compressed by default; smaller ones stay plain. */
6
+ const COMPRESSION_DEFAULTS = { minBytes: 1024, quality: 5 };
7
+ /**
8
+ * Stored-body budget inside DynamoDB's 400KB item limit (headers, keys and
9
+ * attributes need headroom). Applies to the bytes actually stored, so a
10
+ * large compressible response (JSON usually shrinks 5-10x) still replays.
11
+ */
12
+ const MAX_STORED_BODY_BYTES = 350_000;
13
+ /**
14
+ * Ceiling on a stored body's declared length, which is the budget the restore
15
+ * decompresses under. The store's own writes stay far inside it (a response
16
+ * that reaches a client at all is a few megabytes at most, and the compressed
17
+ * bytes have to fit MAX_STORED_BODY_BYTES), so a record declaring more than
18
+ * this is one this store did not write, and taking its word for it would let
19
+ * a few hundred kilobytes of Brotli expand until the function dies. The
20
+ * cache bounds the same number the same way, against its maxValueBytes.
21
+ */
22
+ const MAX_REPLAY_BODY_BYTES = 32 * 1024 * 1024;
23
+ /**
24
+ * A number attribute as stored, or the fallback when it is missing or not a
25
+ * number. `Number(undefined)` and `Number("nope")` are both NaN, which every
26
+ * later comparison answers false to: a NaN expiry reads as "not expired" and
27
+ * a NaN status code reaches the client as one.
28
+ */
29
+ const storedNumber = (raw, fallback) => {
30
+ const value = Number(raw);
31
+ return Number.isFinite(value) ? value : fallback;
32
+ };
33
+ /** 16 random bytes, hex, through the optional-crypto seam so a bundler's browser stub cannot break the import. */
34
+ const newOwnerToken = async () => {
35
+ const crypto = await getCrypto();
36
+ if (!crypto)
37
+ throw new Error("LambderDdbIdempotencyStore requires a Node.js environment.");
38
+ return crypto.randomBytes(16).toString("hex");
39
+ };
40
+ /**
41
+ * DynamoDB-backed idempotency records: one item per (identity, api, key)
42
+ * scope, claimed atomically with a conditional put. The first request claims
43
+ * the scope as "pending"; concurrent duplicates see "pending"; once the
44
+ * response is stored via complete(), replays get it back verbatim until the
45
+ * TTL. Records whose expiresAt has passed count as absent (DynamoDB TTL
46
+ * deletion is lazy, so expiry is enforced in the condition, not left to TTL).
47
+ *
48
+ * Every claim carries a random ownerToken, and complete()/abandon() are
49
+ * conditional on still holding it: an original that outlives its pending TTL
50
+ * and loses the scope to a retry can no longer overwrite or delete the
51
+ * retry's claim (both settle calls become silent no-ops instead). complete()
52
+ * also requires the claim to be unexpired, so an owner whose claim ran out
53
+ * reports "lost" whether or not TTL deletion has caught up with it, which is
54
+ * what the memory store has always reported.
55
+ *
56
+ * Stored bodies are Brotli-compressed from 1KB by default (same scheme as
57
+ * LambderDdbCache, see the `compression` option): the bodies are JSON
58
+ * envelopes that typically shrink 5-10x, which cuts DynamoDB write units
59
+ * and lets large responses fit the item budget instead of skipping replay
60
+ * storage.
61
+ *
62
+ * The scope key carries caller data (the client's idempotency key, and an
63
+ * identity when one is configured), so a scope whose partition key would pass
64
+ * DynamoDB's 2048-byte limit is refused here with an error that names the
65
+ * limit, rather than reaching the table and coming back as a
66
+ * ValidationException that reads as "the table is broken".
67
+ *
68
+ * Table shape: string hash key `pk`, string range key `sk`, TTL on
69
+ * `expiresAt`. Items are prefixed `IDEM#` by default, so the table can be
70
+ * shared with LambderDdbRateLimiter (`RL#`) and LambderDdbCache (`CACHE#`)
71
+ * without key collisions.
72
+ */
73
+ export class LambderDdbIdempotencyStore {
74
+ tableName;
75
+ keyPrefix;
76
+ compression;
77
+ /** The SDK and the client, loaded and created the first time the table is touched (see LambderDdbSdk). */
78
+ ready;
79
+ now;
80
+ constructor(options) {
81
+ if (!options.tableName.trim())
82
+ throw new Error("tableName is required");
83
+ this.tableName = options.tableName;
84
+ this.keyPrefix = options.keyPrefix ?? "IDEM";
85
+ this.compression = resolveCompressionOption(options.compression, COMPRESSION_DEFAULTS);
86
+ this.now = options.now ?? (() => Date.now());
87
+ this.ready = createDynamoClientLoader({ user: "LambderDdbIdempotencyStore", region: options.region, client: options.client });
88
+ }
89
+ nowSeconds() { return Math.floor(this.now() / 1000); }
90
+ itemKey(scopeKey) {
91
+ const partitionKey = assertPartitionKeyFits({
92
+ user: "LambderDdbIdempotencyStore",
93
+ what: "scope key",
94
+ partitionKey: `${this.keyPrefix}#${scopeKey}`,
95
+ remedy: "Shorten the idempotency key or the identity it is scoped by.",
96
+ });
97
+ return { pk: { S: partitionKey }, sk: { S: "idem" } };
98
+ }
99
+ /**
100
+ * A stored item's response headers: the multi-value map the answer
101
+ * replays. Every entry is checked to BE one, because the engine hands
102
+ * what comes back to the response builder, which would ship a number or a
103
+ * bare string as a header value.
104
+ */
105
+ static readItemHeaders(item) {
106
+ const raw = item.headersJson?.S;
107
+ if (!raw)
108
+ return {};
109
+ let parsed;
110
+ try {
111
+ parsed = JSON.parse(raw);
112
+ }
113
+ catch {
114
+ return {}; // Corrupt record: replay with no headers rather than fail the request.
115
+ }
116
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
117
+ return {};
118
+ const headers = {};
119
+ for (const [name, value] of Object.entries(parsed)) {
120
+ // An own "__proto__" key survives JSON.parse and assigning it here
121
+ // would set this object's prototype instead of adding a header.
122
+ if (name === "__proto__")
123
+ continue;
124
+ if (Array.isArray(value) && value.every((entry) => typeof entry === "string"))
125
+ headers[name] = value;
126
+ }
127
+ return headers;
128
+ }
129
+ /**
130
+ * A stored item's response body: plain (`body`) or Brotli (`bodyBr` +
131
+ * `bodyBytes`). The stored length is the decompression budget, so it is
132
+ * checked here the way the headers are: a record declaring more than this
133
+ * store ever writes is unusable, not an invitation to allocate it.
134
+ */
135
+ static async readItemBody(item) {
136
+ const compressed = item.bodyBr?.B;
137
+ if (!compressed)
138
+ return item.body?.S ?? "";
139
+ const declaredBytes = storedNumber(item.bodyBytes?.N, 0);
140
+ if (declaredBytes > MAX_REPLAY_BODY_BYTES) {
141
+ throw new Error(`LambderDdbIdempotencyStore: the stored body declares ${declaredBytes} bytes, over the ${MAX_REPLAY_BODY_BYTES}-byte replay limit, so the record is unusable.`);
142
+ }
143
+ return await restoreText(compressed, "br", { declaredBytes });
144
+ }
145
+ /** A stored answer as the engine reads it, with every field of the record checked rather than cast. */
146
+ static async answerOf(item) {
147
+ return {
148
+ statusCode: storedNumber(item.statusCode?.N, 200),
149
+ headers: LambderDdbIdempotencyStore.readItemHeaders(item),
150
+ body: await LambderDdbIdempotencyStore.readItemBody(item),
151
+ };
152
+ }
153
+ /**
154
+ * Read the scope without claiming it: the stored response when a
155
+ * completed, unexpired record exists, null otherwise (absent, pending, or
156
+ * expired). Eventually-consistent read: a miss here only means the caller
157
+ * proceeds to begin(), whose read is authoritative.
158
+ */
159
+ async peek(scopeKey) {
160
+ const { client, sdk } = await this.ready();
161
+ const existing = await client.send(new sdk.GetItemCommand({
162
+ TableName: this.tableName,
163
+ Key: this.itemKey(scopeKey),
164
+ }));
165
+ const item = existing.Item;
166
+ if (!item || item.state?.S !== "done")
167
+ return null;
168
+ if (storedNumber(item.expiresAt?.N, 0) <= this.nowSeconds())
169
+ return null;
170
+ return await LambderDdbIdempotencyStore.answerOf(item);
171
+ }
172
+ /**
173
+ * Claim the scope. "new" means this request now owns it (proven by the
174
+ * returned ownerToken) and must call complete() or abandon(); "pending"
175
+ * means another request owns it right now; "done" carries the stored
176
+ * response to replay.
177
+ */
178
+ async begin(scopeKey, { pendingTtlSeconds }) {
179
+ const nowSeconds = this.nowSeconds();
180
+ const ownerToken = await newOwnerToken();
181
+ try {
182
+ const { client, sdk } = await this.ready();
183
+ await client.send(new sdk.PutItemCommand({
184
+ TableName: this.tableName,
185
+ Item: {
186
+ ...this.itemKey(scopeKey),
187
+ state: { S: "pending" },
188
+ ownerToken: { S: ownerToken },
189
+ expiresAt: { N: String(nowSeconds + pendingTtlSeconds) },
190
+ },
191
+ // Every clause is one a missing attribute can satisfy rather
192
+ // than block: DynamoDB reads a comparison whose operand path
193
+ // is absent as FALSE, so a condition that only asked
194
+ // `expiresAt <= :now` refused an item carrying no expiry for
195
+ // ever, and a pending one of those deadlocked its scope with
196
+ // no TTL able to retire it.
197
+ ConditionExpression: "attribute_not_exists(pk) OR attribute_not_exists(expiresAt) OR expiresAt <= :now",
198
+ ExpressionAttributeValues: { ":now": { N: String(nowSeconds) } },
199
+ }));
200
+ return { state: "new", ownerToken };
201
+ }
202
+ catch (error) {
203
+ if (!isConditionalCheckFailure(error))
204
+ throw error;
205
+ }
206
+ const { client, sdk } = await this.ready();
207
+ const existing = await client.send(new sdk.GetItemCommand({
208
+ TableName: this.tableName,
209
+ Key: this.itemKey(scopeKey),
210
+ ConsistentRead: true,
211
+ }));
212
+ const item = existing.Item;
213
+ // Deleted between the put and the read: treat as in-flight, the retry resolves it.
214
+ if (!item)
215
+ return { state: "pending" };
216
+ // The same expiry test peek runs, because the condition above cannot
217
+ // make it: an item whose expiresAt is present but unreadable (a
218
+ // partial write, another writer on a shared table) refuses the claim
219
+ // AND is past nothing, so replaying it here would replay a stored
220
+ // answer for ever. An unreadable expiry counts as expired, and the
221
+ // scope reads as pending rather than as done.
222
+ const live = storedNumber(item.expiresAt?.N, 0) > nowSeconds;
223
+ if (live && item.state?.S === "done")
224
+ return { state: "done", ...await LambderDdbIdempotencyStore.answerOf(item) };
225
+ return { state: "pending" };
226
+ }
227
+ /**
228
+ * Store the response for replays, overwriting the pending claim. Bodies
229
+ * from the compression option's minBytes are stored Brotli-compressed
230
+ * (they are JSON envelopes, which typically shrink 5-10x), cutting
231
+ * DynamoDB write units and letting large responses fit the item budget;
232
+ * smaller bodies, or all of them with compression off, stay plain.
233
+ * Returns:
234
+ *
235
+ * - "stored": the record is in place and will replay.
236
+ * - "too-large": even compressed, the body exceeds the item budget;
237
+ * nothing was written and the caller should release the claim.
238
+ * - "lost": the ownerToken no longer matches, i.e. the claim expired and
239
+ * a retry took the scope over; nothing was written.
240
+ */
241
+ async complete(scopeKey, ownerToken, { statusCode, headers, body, ttlSeconds }) {
242
+ const nowSeconds = this.nowSeconds();
243
+ const rawBody = Buffer.from(body, "utf8");
244
+ let bodyAttributes;
245
+ // A zero-length body is never compressed, whatever minBytes says: it
246
+ // would be stored as `bodyBr` with `bodyBytes: 0`, and a declared
247
+ // length of zero is one the codec refuses on the way back, so the
248
+ // record would be unreadable for its whole TTL and every retry would
249
+ // execute again. The plain path stores it as the empty string, which
250
+ // reads back as one.
251
+ if (this.compression && rawBody.byteLength > 0 && rawBody.byteLength >= this.compression.minBytes) {
252
+ const compressed = await compressText(rawBody, "br", this.compression.quality);
253
+ if (compressed.byteLength > MAX_STORED_BODY_BYTES)
254
+ return "too-large";
255
+ // bodyBytes bounds and verifies decompression on read.
256
+ bodyAttributes = { bodyBr: { B: compressed }, bodyBytes: { N: String(rawBody.byteLength) } };
257
+ }
258
+ else {
259
+ // The budget is on what actually gets stored, so the plain path is
260
+ // measured too. Without this an oversized body reaches DynamoDB and
261
+ // comes back as a ValidationException, which is not a
262
+ // ConditionalCheckFailedException and so escapes as a store error.
263
+ if (rawBody.byteLength > MAX_STORED_BODY_BYTES)
264
+ return "too-large";
265
+ bodyAttributes = { body: { S: body } };
266
+ }
267
+ try {
268
+ const { client, sdk } = await this.ready();
269
+ await client.send(new sdk.PutItemCommand({
270
+ TableName: this.tableName,
271
+ Item: {
272
+ ...this.itemKey(scopeKey),
273
+ state: { S: "done" },
274
+ ownerToken: { S: ownerToken },
275
+ statusCode: { N: String(statusCode) },
276
+ headersJson: { S: JSON.stringify(headers) },
277
+ ...bodyAttributes,
278
+ expiresAt: { N: String(nowSeconds + ttlSeconds) },
279
+ },
280
+ // The claim has to be BOTH still owned and still live. Owner
281
+ // alone let an owner whose claim had already expired store
282
+ // over it, because DynamoDB's TTL deletion is lazy and the
283
+ // expired item is usually still sitting there. The memory
284
+ // store drops an expired entry on read and answered "lost"
285
+ // for the same call, so the two disagreed, and DynamoDB's
286
+ // answer depended on whether AWS had got round to the sweep.
287
+ ConditionExpression: "ownerToken = :owner AND expiresAt > :now",
288
+ ExpressionAttributeValues: { ":owner": { S: ownerToken }, ":now": { N: String(nowSeconds) } },
289
+ }));
290
+ return "stored";
291
+ }
292
+ catch (error) {
293
+ if (!isConditionalCheckFailure(error))
294
+ throw error;
295
+ return "lost";
296
+ }
297
+ }
298
+ /**
299
+ * Release the claim without storing a response (crash, uncacheable
300
+ * response), so a retry can execute. Conditional on still holding the
301
+ * claim; a lost claim makes this a silent no-op.
302
+ */
303
+ async abandon(scopeKey, ownerToken) {
304
+ try {
305
+ const { client, sdk } = await this.ready();
306
+ await client.send(new sdk.DeleteItemCommand({
307
+ TableName: this.tableName,
308
+ Key: this.itemKey(scopeKey),
309
+ ConditionExpression: "ownerToken = :owner",
310
+ ExpressionAttributeValues: { ":owner": { S: ownerToken } },
311
+ }));
312
+ }
313
+ catch (error) {
314
+ if (!isConditionalCheckFailure(error))
315
+ throw error;
316
+ }
317
+ }
318
+ }
319
+ export default LambderDdbIdempotencyStore;
@@ -1,52 +1,20 @@
1
1
  import type { DynamoDBClient } from "@aws-sdk/client-dynamodb";
2
- /**
3
- * The fixed windows a policy may cap, smallest first (the evaluation order),
4
- * with their length. The policy type derives from this table, so the two can
5
- * never drift.
6
- */
7
- export declare const RATE_LIMIT_WINDOWS: readonly [{
8
- readonly key: "perMin";
9
- readonly seconds: 60;
10
- }, {
11
- readonly key: "per10Min";
12
- readonly seconds: number;
13
- }, {
14
- readonly key: "perHour";
15
- readonly seconds: number;
16
- }, {
17
- readonly key: "perDay";
18
- readonly seconds: number;
19
- }, {
20
- readonly key: "perWeek";
21
- readonly seconds: number;
22
- }, {
23
- readonly key: "perMonth";
24
- readonly seconds: number;
25
- }];
26
- export type LambderRateLimitWindow = (typeof RATE_LIMIT_WINDOWS)[number]["key"];
27
- /** Per-window caps. A window that is absent or 0 is not enforced. */
28
- export type LambderRateLimitPolicy = Partial<Record<LambderRateLimitWindow, number>>;
29
- /**
30
- * The window that refused: which one, its limit, and the epoch second at
31
- * which that fixed window resets (Retry-After derives from it).
32
- */
33
- export type LambderRateLimitExceeded = {
34
- window: LambderRateLimitWindow;
35
- limit: number;
36
- resetAt: number;
37
- };
38
- /** `false` when allowed, otherwise the window whose limit was hit. */
39
- export type LambderRateLimitResult = false | LambderRateLimitExceeded;
2
+ import { type LambderRateLimiter, type LambderRateLimitPolicy, type LambderRateLimitResult } from "../shared/contracts/LambderRateLimiter.js";
40
3
  export interface LambderDdbRateLimiterOptions {
41
4
  tableName: string;
5
+ /** Region the client is created for on first use; the SDK's default chain otherwise. */
42
6
  region?: string;
43
7
  /** Partition key prefix, keeps counters separated from other systems in a shared table. Default: "RL". */
44
8
  keyPrefix?: string;
45
9
  /** Multiplier applied to the window length when setting the item TTL. */
46
10
  ttlWindowMultiplier?: number;
47
- /** Allow the request when DynamoDB itself errors. Defaults to false. */
48
- failOpen?: boolean;
49
11
  client?: DynamoDBClient;
12
+ /**
13
+ * The clock the windows are computed against, injectable the way
14
+ * LambderMemoryRateLimiter's is, so the conformance suite can drive both
15
+ * implementations across a window boundary through one clock.
16
+ */
17
+ now?: () => number;
50
18
  }
51
19
  /**
52
20
  * Fixed-window rate limiter backed by DynamoDB.
@@ -60,29 +28,42 @@ export interface LambderDdbRateLimiterOptions {
60
28
  * would give up the conditional-ADD atomicity). Items carry an `expiresAt`
61
29
  * attribute for DynamoDB TTL.
62
30
  *
31
+ * The tracker key is caller data (an address, a session key, whatever a
32
+ * policy handler returned), so a key whose partition key would pass
33
+ * DynamoDB's 2048-byte limit is refused here, before any window is counted,
34
+ * rather than reaching the table and coming back as a ValidationException:
35
+ * that is not a conditional-check failure, so it escapes as a store error and
36
+ * a caller failing open on it counts nothing at all, which is the limit
37
+ * silently off. Lambder's own engine folds an over-long key into a digest
38
+ * long before this, so a key that gets here came from a direct caller.
39
+ *
40
+ * A DynamoDB error propagates: a limiter says whether the caller is over its
41
+ * limit, and it cannot answer that question when it cannot reach the table.
42
+ * Whether an unanswerable limit lets the request through is the application's
43
+ * call, not the storage's, so it is made once for every limiter at
44
+ * `rateLimits.failOpen` and the engine there handles the throw.
45
+ *
63
46
  * Table shape: string hash key `pk`, string range key `sk`, TTL on `expiresAt`.
64
47
  * Items are prefixed `RL#` by default, so the table can be shared with
65
- * LambderDdbCache (`CACHE#`) and LambderDdbIdempotency (`IDEM#`) without key
48
+ * LambderDdbCache (`CACHE#`) and LambderDdbIdempotencyStore (`IDEM#`) without key
66
49
  * collisions.
67
50
  */
68
- export declare class LambderDdbRateLimiter {
51
+ export declare class LambderDdbRateLimiter implements LambderRateLimiter {
69
52
  readonly tableName: string;
70
53
  readonly keyPrefix: string;
71
- /** The client given at creation, or one created from `region` on first use; the SDK arrives with it. */
72
- private readonly providedClient;
73
- private readonly region;
74
- private readyPromise;
54
+ /** The SDK and the client, loaded and created the first time the table is touched (see LambderDdbSdk). */
55
+ private readonly ready;
75
56
  private readonly ttlWindowMultiplier;
76
- private readonly failOpen;
57
+ private readonly now;
77
58
  constructor(options: LambderDdbRateLimiterOptions);
78
- /** The SDK and the client, loaded and created the first time the table is touched (see LambderDdbSdk). */
79
- private ready;
80
59
  /**
81
60
  * Increment every configured window for `trackerKey` (IP, session, user id, ...)
82
61
  * and report whether any of them is over its limit, with the window's
83
62
  * reset time when so.
84
63
  */
85
64
  isRateLimited(trackerKey: string, policy: LambderRateLimitPolicy): Promise<LambderRateLimitResult>;
65
+ /** The item's partition key, refused when the tracker key makes it one DynamoDB will not take. */
66
+ private partitionKeyFor;
86
67
  /** Increments one window counter. Returns true when the limit was already reached. */
87
68
  private incrementWindow;
88
69
  }