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
@@ -0,0 +1,330 @@
1
+ import { getAnswerHeader } from "../shared/wire/LambderAnswerHeaders.js";
2
+ import { LambderApiRefusal, LAMBDER_REFUSAL_CODES } from "../shared/wire/LambderApiRefusal.js";
3
+ import { joinKeyFields } from "../shared/util/LambderKeyFields.js";
4
+ import { assertPositiveInteger } from "../shared/util/LambderOptionChecks.js";
5
+ /**
6
+ * A crashed original must not block retries forever, so a pending claim
7
+ * expires on its own. The default is five minutes, which covers the great
8
+ * majority of handlers.
9
+ *
10
+ * It has to outlive the handler, though, and that is the app's business to
11
+ * know: a Lambda may run for fifteen minutes, and a claim that expires while
12
+ * its own handler is still working hands the next retry a free scope, so the
13
+ * operation runs a second time, which is the one thing idempotency exists to
14
+ * prevent. An app whose handlers can run long should raise it to just past
15
+ * its own timeout through `idempotency: { pendingTtlSeconds }`, or per API.
16
+ */
17
+ const DEFAULT_IDEMPOTENCY_PENDING_TTL_SECONDS = 300;
18
+ /**
19
+ * Keys must be unguessable: without a session, the replay scope is the key
20
+ * itself, so a guessable key would let one client read another's stored
21
+ * response. LambderCaller.createIdempotencyKey() returns 36 chars.
22
+ */
23
+ const IDEMPOTENCY_MIN_KEY_LENGTH = 16;
24
+ const IDEMPOTENCY_MAX_KEY_LENGTH = 200;
25
+ /** A stored record as an answer: the three fields, nothing else. */
26
+ const answerFromRecord = (record) => ({
27
+ statusCode: record.statusCode,
28
+ headers: record.headers,
29
+ body: record.body,
30
+ });
31
+ /**
32
+ * A replay window a store can act on, checked where the rate limiter checks
33
+ * its own windows. NaN was the one that mattered: it survives every
34
+ * comparison an expiry test makes, so an in-memory record with a NaN expiry
35
+ * outlives every sweep, while DynamoDB rejects the same number outright. Zero
36
+ * is rejected too, because a record that expires the instant it is written
37
+ * silently turns replay off for the API that asked for it.
38
+ */
39
+ const assertReplayTtl = (subject, ttlSeconds) => {
40
+ if (ttlSeconds === undefined)
41
+ return;
42
+ // The shared positive-integer check, with the subject naming the option;
43
+ // the wording "a replay window is a positive whole number of seconds"
44
+ // lived here alone and said the same thing in different words.
45
+ assertPositiveInteger(ttlSeconds, `${subject} (a replay window in whole seconds)`);
46
+ };
47
+ /** A per-API replay window, falling back to the configured default. Written once: the two windows resolve the same way. */
48
+ const resolveWindowSeconds = (config, field, fallback) => (typeof config === "object" ? config[field] : undefined) ?? fallback;
49
+ /**
50
+ * Headers the store may keep: the map and every value list copied, so what
51
+ * the engine hands complete() cannot be rewritten afterwards.
52
+ *
53
+ * The pipeline applies the CALL's headers onto the answer on the way out,
54
+ * into the very object handed here. A store that keeps the reference it was
55
+ * given (the interface asks for a copy, and the shipped stores make one, but
56
+ * a custom store is under no compiler's supervision) would have this call's
57
+ * Set-Cookie become part of the stored record and replay to everyone.
58
+ */
59
+ const copyAnswerHeaders = (headers) => Object.fromEntries(Object.entries(headers).map(([name, values]) => [name, [...values]]));
60
+ /**
61
+ * A store failure the engine decided to ignore, said out loud. Failing open
62
+ * is the right default (a store outage should not take the app down with it),
63
+ * but it is also indistinguishable from working: the failure class includes
64
+ * permanent ones (a missing table, a missing IAM action, an SDK that would
65
+ * not install), and an app can run for months executing every retry twice
66
+ * with nothing in its logs. The scope key never appears, since it carries the
67
+ * caller's identity and their posted key.
68
+ */
69
+ const reportFailOpen = (apiName, attempted, err) => {
70
+ console.error(`Lambder idempotency: "${apiName}" could not ${attempted}; the request is being executed as if it carried no idempotency key. ` +
71
+ "Set idempotency.failOpen: false to refuse instead.", err);
72
+ };
73
+ /**
74
+ * Runtime side of the idempotency subsystem: claims a per-operation scope
75
+ * around handler execution, replays stored answers, and settles claims.
76
+ * Composed into LambderApiPolicyEngine. Works on plain answers, so it runs
77
+ * unchanged under the server and the mock runtime.
78
+ */
79
+ export class LambderApiIdempotencyEngine {
80
+ store = null;
81
+ defaultTtlSeconds = 24 * 3600;
82
+ defaultPendingTtlSeconds = DEFAULT_IDEMPOTENCY_PENDING_TTL_SECONDS;
83
+ failOpen = true;
84
+ callerIdentity = undefined;
85
+ /**
86
+ * The scope this call resolved to, keyed by its context, which is the one
87
+ * object per call the engine is handed. A keyed request asks for it
88
+ * twice, at the replay lookup and at the claim, and callerIdentity is app
89
+ * code that may verify a token or read a store: running it twice per
90
+ * request is a cost the app never asked for, and one it cannot see.
91
+ * Entries go when the call's context does.
92
+ */
93
+ scopeByCall = new WeakMap();
94
+ configure(config) {
95
+ if (this.store)
96
+ throw new Error("Lambder: idempotency was already configured.");
97
+ assertReplayTtl("the idempotency option's defaultTtlSeconds", config.defaultTtlSeconds);
98
+ assertReplayTtl("the idempotency option's defaultPendingTtlSeconds", config.defaultPendingTtlSeconds);
99
+ this.store = config.store;
100
+ this.defaultTtlSeconds = config.defaultTtlSeconds ?? 24 * 3600;
101
+ this.defaultPendingTtlSeconds = config.defaultPendingTtlSeconds ?? DEFAULT_IDEMPOTENCY_PENDING_TTL_SECONDS;
102
+ this.failOpen = config.failOpen ?? true;
103
+ this.callerIdentity = config.callerIdentity;
104
+ }
105
+ /** Startup validation of one API registration's idempotency option. */
106
+ assertRegistration(apiName, config) {
107
+ if (typeof config !== "object")
108
+ return;
109
+ assertReplayTtl(`API "${apiName}" idempotency ttlSeconds`, config.ttlSeconds);
110
+ assertReplayTtl(`API "${apiName}" idempotency pendingTtlSeconds`, config.pendingTtlSeconds);
111
+ }
112
+ /** True once the idempotency option was configured; registration asserts check it. */
113
+ get isConfigured() { return this.store !== null; }
114
+ /**
115
+ * The request's idempotencyKey: null when absent, the key when valid, a
116
+ * 400 refusal when malformed. The minimum length matters for security:
117
+ * see IDEMPOTENCY_MIN_KEY_LENGTH.
118
+ */
119
+ readKey(request) {
120
+ const rawKey = request.idempotencyKey;
121
+ if (rawKey === undefined || rawKey === null)
122
+ return null;
123
+ if (typeof rawKey !== "string" || rawKey.length < IDEMPOTENCY_MIN_KEY_LENGTH || rawKey.length > IDEMPOTENCY_MAX_KEY_LENGTH) {
124
+ const content = `Invalid idempotency key: must be a string of ${IDEMPOTENCY_MIN_KEY_LENGTH}-${IDEMPOTENCY_MAX_KEY_LENGTH} characters.`;
125
+ throw new LambderApiRefusal(content, {
126
+ statusCode: 400,
127
+ errorMessage: { type: "error", code: LAMBDER_REFUSAL_CODES.invalidIdempotencyKey, content },
128
+ });
129
+ }
130
+ return rawKey;
131
+ }
132
+ /**
133
+ * The record's scope. Session APIs scope per session, so even a leaked
134
+ * key cannot cross users. Public APIs scope by the key alone unless the
135
+ * app supplies callerIdentity, because the key is required to be long
136
+ * (and documented to be random), and identity proxies like the client IP
137
+ * are deliberately NOT part of the scope: the retry idempotency exists
138
+ * for (a timeout followed by a network change) frequently arrives from a
139
+ * different IP. An app whose public APIs are authorized by a guard should
140
+ * give callerIdentity, since the replay is served before guards run.
141
+ *
142
+ * Fields are escaped and joined through joinKeyFields, so no two distinct
143
+ * scopes can produce one string.
144
+ */
145
+ async scopeOf(apiName, ctx, request, key) {
146
+ const cached = this.scopeByCall.get(ctx);
147
+ if (cached)
148
+ return await cached;
149
+ const computed = this.computeScope(apiName, ctx, request, key);
150
+ this.scopeByCall.set(ctx, computed);
151
+ return await computed;
152
+ }
153
+ async computeScope(apiName, ctx, request, key) {
154
+ const sessionKey = ctx.session?.sessionKey;
155
+ if (sessionKey)
156
+ return joinKeyFields(`s:${sessionKey}`, apiName, key);
157
+ // No session to scope by. The app may still say who this is, through
158
+ // callerIdentity; without one the key alone is the scope, which is
159
+ // what makes it a bearer token for its own answer.
160
+ const identity = this.callerIdentity ? await this.callerIdentity(ctx, request) : null;
161
+ return identity
162
+ ? joinKeyFields(`i:${identity}`, apiName, key)
163
+ : joinKeyFields("k", apiName, key);
164
+ }
165
+ /**
166
+ * Replay fast path, run before the remaining rate limits and before
167
+ * guards: a completed record answers with its stored answer, so a
168
+ * legitimate retry neither burns rate-limit quota nor re-runs guards (the
169
+ * original already passed them, and no handler executes). The `per: "ip"`
170
+ * limits are the exception and are checked ahead of this, since the store
171
+ * read a replay costs is one of the things they exist to bound. Misses
172
+ * fall through to the normal pipeline; store errors follow the failOpen
173
+ * setting.
174
+ */
175
+ async findReplay(apiName, request, ctx, trace) {
176
+ const store = this.store;
177
+ if (!store)
178
+ return null;
179
+ const key = this.readKey(request);
180
+ if (key === null)
181
+ return null;
182
+ try {
183
+ const done = await store.peek(await this.scopeOf(apiName, ctx, request, key));
184
+ if (!done)
185
+ return null;
186
+ trace.replayed = true;
187
+ return answerFromRecord(done);
188
+ }
189
+ catch (err) {
190
+ if (!this.failOpen)
191
+ throw err;
192
+ reportFailOpen(apiName, "look its replay record up", err);
193
+ return null;
194
+ }
195
+ }
196
+ /**
197
+ * Idempotency wrapper around validation-passed handler execution. Without
198
+ * a client idempotencyKey the handler just runs; with one, the scope
199
+ * (identity + api + key) is claimed atomically: duplicates of an
200
+ * in-flight original refuse with 409, replays of a completed one return
201
+ * the stored answer verbatim, and a crashed original releases its claim
202
+ * so a retry actually retries.
203
+ *
204
+ * `exec` must hand back the handler's own answer, the headers the handler
205
+ * itself wrote included: the pipeline applies those before returning here,
206
+ * so a Set-Cookie the handler set is visible to the caching rule below.
207
+ * Headers written EARLIER in the call (a session read evicting a stale
208
+ * cookie) are deliberately not on it: they are the call's, they reach the
209
+ * client either way, and charging them to this answer would make an
210
+ * idempotent operation silently stop being idempotent.
211
+ */
212
+ async withIdempotency(apiName, request, ctx, config, trace, exec) {
213
+ const store = this.store;
214
+ if (!store)
215
+ return await exec();
216
+ const rawKey = this.readKey(request);
217
+ if (rawKey === null)
218
+ return await exec();
219
+ const ttlSeconds = resolveWindowSeconds(config, "ttlSeconds", this.defaultTtlSeconds);
220
+ const pendingTtlSeconds = resolveWindowSeconds(config, "pendingTtlSeconds", this.defaultPendingTtlSeconds);
221
+ const scopeKey = await this.scopeOf(apiName, ctx, request, rawKey);
222
+ let begun;
223
+ try {
224
+ begun = await store.begin(scopeKey, { pendingTtlSeconds });
225
+ }
226
+ catch (err) {
227
+ if (!this.failOpen)
228
+ throw err;
229
+ reportFailOpen(apiName, "claim its scope", err);
230
+ return await exec();
231
+ }
232
+ if (begun.state === "pending") {
233
+ throw new LambderApiRefusal(`Duplicate request for "${apiName}": the original is still processing.`, {
234
+ statusCode: 409,
235
+ errorMessage: { type: "warning", code: LAMBDER_REFUSAL_CODES.duplicateInFlight, content: "This request is already being processed." },
236
+ });
237
+ }
238
+ // The other replay path: the original settled between this request's
239
+ // peek and its claim, which is the race the "done" answer exists for.
240
+ // No handler runs here either, so it is a replay like any other.
241
+ if (begun.state === "done") {
242
+ trace.replayed = true;
243
+ return answerFromRecord(begun);
244
+ }
245
+ const ownerToken = begun.ownerToken;
246
+ // Store the answer for replays when it qualifies, release the claim
247
+ // otherwise. A Set-Cookie makes an answer uncacheable (replaying
248
+ // another request's cookies, e.g. session tokens, would be wrong), and
249
+ // so does a binary body.
250
+ //
251
+ // Settling happens after the handler has already run, so a store
252
+ // failure here can no longer prevent anything: the work is done and
253
+ // the answer is owed to the caller. failOpen governs the decision
254
+ // BEFORE execution, at begin(), where refusing still means refusing to
255
+ // act. Applying it here would turn a completed operation into a 500
256
+ // and hand the retry a released claim, which is exactly the double
257
+ // execution idempotency exists to prevent, so a settle failure is
258
+ // reported and swallowed.
259
+ const settleClaim = async (answer) => {
260
+ const cacheable = answer.statusCode < 500
261
+ && !answer.isBodyBase64
262
+ && getAnswerHeader(answer.headers, "Set-Cookie") === undefined;
263
+ try {
264
+ if (cacheable) {
265
+ // The store owns the size decision: bodies may be compressed
266
+ // there, and only ones exceeding its budget even compressed
267
+ // come back as "too-large".
268
+ const completion = await store.complete(scopeKey, ownerToken, {
269
+ statusCode: answer.statusCode,
270
+ // Copied, not handed over: the pipeline is about to
271
+ // apply this call's own headers into answer.headers,
272
+ // and a store that kept the reference would have them
273
+ // in the record.
274
+ headers: copyAnswerHeaders(answer.headers),
275
+ body: answer.body,
276
+ ttlSeconds,
277
+ });
278
+ if (completion !== "too-large")
279
+ return;
280
+ // Too large to replay: fall through to release the claim
281
+ // so retries re-execute instead of 409ing.
282
+ }
283
+ await store.abandon(scopeKey, ownerToken);
284
+ }
285
+ catch (storeErr) {
286
+ // Two cases, one release. A complete() that never landed must
287
+ // not leave the pending claim dangling: it would 409 the
288
+ // caller's genuine retries until the pending TTL runs out. A
289
+ // complete() whose write DID land and then timed out is
290
+ // released too, so the record stops replaying and a retry
291
+ // re-executes rather than replaying. That is the safe
292
+ // direction of the two: the work has already been done once
293
+ // and its answer is with the caller, so a retry that runs
294
+ // again costs an execution, while a claim nobody can clear is
295
+ // a caller who cannot get through at all.
296
+ try {
297
+ await store.abandon(scopeKey, ownerToken);
298
+ }
299
+ catch { /* claim expires on its own */ }
300
+ console.warn(`Lambder idempotency: "${apiName}" ran and answered, but its record could not be stored. ` +
301
+ "The caller keeps this answer; a retry under the same key will execute again.", storeErr);
302
+ }
303
+ };
304
+ try {
305
+ const answer = await exec();
306
+ await settleClaim(answer);
307
+ return answer;
308
+ }
309
+ catch (err) {
310
+ // A real crash, or a thrown refusal (LambderApiRefusal, refuse()),
311
+ // releases the claim so a retry actually retries. This is the
312
+ // deliberate rule: ANSWERS are stored and replayed, refusals
313
+ // delivered as returned envelopes included; EXCEPTIONS are not, so
314
+ // a thrown refusal re-executes on retry and the handler decides
315
+ // afresh. Pick the idiom accordingly. (On the server, a response
316
+ // delivered by throwing, res.die.api(), is an answer: the adapter
317
+ // catches it before it reaches here.)
318
+ // Whatever happens to the claim, the handler's own failure is the
319
+ // one worth reporting: replacing it with a cleanup error would
320
+ // hide the reason the call failed.
321
+ try {
322
+ await store.abandon(scopeKey, ownerToken);
323
+ }
324
+ catch (cleanupErr) {
325
+ console.warn(`Lambder idempotency: releasing the claim for "${apiName}" failed; it expires on its own.`, cleanupErr);
326
+ }
327
+ throw err;
328
+ }
329
+ }
330
+ }
@@ -0,0 +1,134 @@
1
+ import type { z } from "zod";
2
+ import type { LambderApiRequest } from "./LambderApiRequest.js";
3
+ import type { LambderApiAnswer } from "./LambderApiAnswer.js";
4
+ import type { LambderApiCallContext } from "./LambderApiCallContext.js";
5
+ import type { LambderApiCallTrace } from "./LambderApiCallContext.js";
6
+ import type { LambderApiDefinition } from "./LambderApiDefinition.js";
7
+ import type { LambderApiGuard } from "./LambderApiGuards.js";
8
+ import type { LambderApiRateLimitPolicyConfig, LambderApiRateLimitsConfig } from "./LambderApiRateLimits.js";
9
+ import type { LambderApiIdempotencyConfig } from "./LambderApiIdempotency.js";
10
+ import type { LambderSessionRecord } from "../shared/contracts/LambderSessionStore.js";
11
+ import type LambderSessionManager from "../session/LambderSessionManager.js";
12
+ import LambderSessionController, { type LambderSessionCookieOptions, type LambderSessionRequestInfo } from "../session/LambderSessionController.js";
13
+ import type { MaybePromise } from "../shared/util/LambderTypeUtilities.js";
14
+ /**
15
+ * The app's own answer for a rejected input (setApiInputValidationErrorHandler
16
+ * on the server). Returning null asks for the standard 422 body, which is
17
+ * what an adapter whose app set no handler answers: the rule lives in the
18
+ * pipeline alone, so "no handler, standard 422" is written once. The API's
19
+ * schema and every preflight slice (guard inputs, rate-limit keys) answer
20
+ * through here, so one failure has one shape.
21
+ */
22
+ export type LambderApiInputRefusal<TCtx> = (zodError: z.ZodError, ctx: TCtx, request: LambderApiRequest) => MaybePromise<LambderApiAnswer | null>;
23
+ /** The session subsystem as the pipeline runs it: the manager plus the cookie names and scope the controller writes. */
24
+ export type LambderApiSessionsConfig<TSessionData> = {
25
+ manager: LambderSessionManager<TSessionData>;
26
+ tokenCookieKey?: string;
27
+ csrfCookieKey?: string;
28
+ cookieOptions?: LambderSessionCookieOptions;
29
+ };
30
+ export type LambderApiPipelineOptions<TCtx extends LambderApiCallContext<TSessionData>, TSessionData = any> = {
31
+ /** Enables the version gate: a request naming another version answers versionExpired. */
32
+ apiVersion?: string | null;
33
+ /** Ceiling on what a compressed request payload may restore to. Default: 20,000,000. */
34
+ maxRequestPayloadBytes?: number;
35
+ onInvalidInput?: LambderApiInputRefusal<TCtx>;
36
+ sessions?: LambderApiSessionsConfig<TSessionData>;
37
+ rateLimits?: LambderApiRateLimitsConfig<Record<string, LambderApiRateLimitPolicyConfig<TCtx>>>;
38
+ guards?: Record<string, LambderApiGuard<any, any, any, TCtx, TCtx & {
39
+ session: LambderSessionRecord<TSessionData>;
40
+ }>>;
41
+ idempotency?: LambderApiIdempotencyConfig;
42
+ };
43
+ /** What one run produced, beside the answer: what an adapter may want to report. */
44
+ export type LambderApiRunResult = LambderApiCallTrace & {
45
+ answer: LambderApiAnswer;
46
+ };
47
+ /** The adapter's step: run the endpoint's handler on the context and hand back its answer. */
48
+ export type LambderApiExec<TCtx> = (ctx: TCtx) => Promise<LambderApiAnswer>;
49
+ /**
50
+ * The API pipeline: one API call from a parsed request to a plain answer,
51
+ * in the order the protocol defines. The Lambda server and the mock runtime
52
+ * are adapters over this class; neither reimplements a step of it.
53
+ *
54
+ * ```
55
+ * version gate → restore payload → rate limits that need no session
56
+ * → session (session mode) → idempotency replay → the remaining rate limits
57
+ * → guards → input validation → exec, inside the idempotency claim
58
+ * → drain response headers → answer
59
+ * ```
60
+ *
61
+ * Steps whose subsystem is not configured are skipped. A LambderApiRefusal
62
+ * thrown by any step, guard or handler is rendered here, in one place: a
63
+ * validation error through onInvalidInput, any other refusal as the refusal
64
+ * envelope. Anything else propagates, because only the adapter knows what a
65
+ * crash means (a global error handler, a mock event).
66
+ *
67
+ * `run` never sees a name it has no definition for; resolving a name to a
68
+ * definition is the one thing the adapters legitimately do differently (an
69
+ * action list versus a registry), and answerUnknownApi is what they answer
70
+ * with.
71
+ */
72
+ export declare class LambderApiPipeline<TCtx extends LambderApiCallContext<TSessionData>, TSessionData = any> {
73
+ readonly apiVersion: string | null;
74
+ private readonly policies;
75
+ private readonly maxRequestPayloadBytes;
76
+ private readonly onInvalidInput;
77
+ private readonly sessions;
78
+ constructor(options?: LambderApiPipelineOptions<TCtx, TSessionData>);
79
+ /** True when a session manager was configured. */
80
+ get hasSessions(): boolean;
81
+ /** The session manager, for adapters that hand it out; throws when sessions are not configured. */
82
+ get sessionManager(): LambderSessionManager<TSessionData>;
83
+ /**
84
+ * A session controller for one request: what handlers use to create,
85
+ * rotate, refresh and end sessions. The request info is the API request's
86
+ * (its cookies and posted CSRF token) or a route's (cookies and no CSRF).
87
+ */
88
+ sessionController(ctx: TCtx, request: LambderSessionRequestInfo): LambderSessionController<TSessionData>;
89
+ /** The session request info of an API request: its cookies, and the CSRF token it posted. */
90
+ static sessionInfoOf(request: LambderApiRequest): LambderSessionRequestInfo;
91
+ /** Registration-time checks of one definition's declarative options; the same messages on the server and in the mock. */
92
+ assertRegistration(definition: LambderApiDefinition): void;
93
+ /** True when the gate is on and the request names a different version. */
94
+ isVersionStale(request: LambderApiRequest): boolean;
95
+ /**
96
+ * The answer for a request naming no registered API: the apiNotFound
97
+ * refusal, carrying whatever the call already wrote (a CORS header, a
98
+ * cookie eviction). No version gate here: both adapters run prepare() on
99
+ * the way in, before a name is resolved, so a stale client has already
100
+ * been answered by the time anything asks for an unknown name.
101
+ */
102
+ answerUnknownApi(request: LambderApiRequest, ctx?: TCtx): LambderApiAnswer;
103
+ /**
104
+ * The steps that come before anything may read the request: the version
105
+ * gate, then the compressed-payload restore that every later reader (a
106
+ * rate-limit key slice, a guard, the input schema) depends on having
107
+ * happened.
108
+ *
109
+ * Public and named because the server runs them earlier than run() does,
110
+ * on the way in, so that its hooks see a plain payload and a stale client
111
+ * is answered before any of them, whether or not the name it asked for
112
+ * exists. run() calls it too, so an adapter that has no such step still
113
+ * gets the whole protocol. Calling it twice is safe by construction: the
114
+ * gate is a pure comparison and the restore has already removed the wire
115
+ * fields it reads.
116
+ *
117
+ * Returns the answer that ends the call, or null when the request is
118
+ * ready to dispatch.
119
+ */
120
+ prepare(request: LambderApiRequest): Promise<LambderApiAnswer | null>;
121
+ /**
122
+ * One call, one answer. Refusals are rendered; crashes propagate.
123
+ *
124
+ * An adapter that wants to report what the call did even when it crashed
125
+ * passes its own trace object: the pipeline writes into that one, so a
126
+ * handler that threw still leaves the guards it ran behind for the
127
+ * adapter's catch. Without it the trace was created here and lost with
128
+ * the throw, and the mock's call log showed no guards on exactly the
129
+ * calls a developer opens the log for.
130
+ */
131
+ run(request: LambderApiRequest, ctx: TCtx, definition: LambderApiDefinition, exec: LambderApiExec<TCtx>, trace?: LambderApiCallTrace): Promise<LambderApiRunResult>;
132
+ private execute;
133
+ private refuseInput;
134
+ }