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.
- package/CHANGELOG.md +2316 -0
- package/README.md +60 -33
- package/dist/api/LambderApiAnswer.d.ts +40 -0
- package/dist/api/LambderApiAnswer.js +19 -0
- package/dist/api/LambderApiCallContext.d.ts +38 -0
- package/dist/api/LambderApiCallContext.js +13 -0
- package/dist/api/LambderApiDefinition.d.ts +18 -0
- package/dist/api/LambderApiDefinition.js +1 -0
- package/dist/api/LambderApiEnvelope.d.ts +67 -0
- package/dist/api/LambderApiEnvelope.js +180 -0
- package/dist/api/LambderApiGuards.d.ts +302 -0
- package/dist/api/LambderApiGuards.js +134 -0
- package/dist/api/LambderApiIdempotency.d.ts +122 -0
- package/dist/api/LambderApiIdempotency.js +330 -0
- package/dist/api/LambderApiPipeline.d.ts +134 -0
- package/dist/api/LambderApiPipeline.js +221 -0
- package/dist/api/LambderApiPolicyEngine.d.ts +36 -0
- package/dist/api/LambderApiPolicyEngine.js +77 -0
- package/dist/api/LambderApiRateLimits.d.ts +206 -0
- package/dist/api/LambderApiRateLimits.js +239 -0
- package/dist/api/LambderApiRequest.d.ts +101 -0
- package/dist/api/LambderApiRequest.js +129 -0
- package/dist/api/LambderApiValidationRefusal.d.ts +32 -0
- package/dist/api/LambderApiValidationRefusal.js +40 -0
- package/dist/client/LambderCaller.d.ts +62 -55
- package/dist/client/LambderCaller.js +147 -90
- package/dist/client/lambderFetchTransport.d.ts +9 -0
- package/dist/client/lambderFetchTransport.js +71 -0
- package/dist/client.d.ts +20 -10
- package/dist/client.js +11 -5
- package/dist/core/Lambder.d.ts +117 -253
- package/dist/core/Lambder.js +374 -341
- package/dist/core/LambderContext.d.ts +54 -44
- package/dist/core/LambderContext.js +41 -110
- package/dist/core/LambderCreateOptions.d.ts +285 -0
- package/dist/core/LambderCreateOptions.js +44 -0
- package/dist/core/LambderFiles.d.ts +1 -45
- package/dist/core/LambderFiles.js +18 -38
- package/dist/core/LambderIndexHtml.d.ts +37 -0
- package/dist/core/LambderIndexHtml.js +87 -0
- package/dist/core/LambderPolicyBuilders.d.ts +17 -0
- package/dist/core/LambderPolicyBuilders.js +16 -0
- package/dist/core/LambderPublicFiles.d.ts +5 -2
- package/dist/core/LambderPublicFiles.js +7 -2
- package/dist/core/LambderResolver.d.ts +8 -6
- package/dist/core/LambderResponse.d.ts +29 -11
- package/dist/core/LambderResponse.js +96 -49
- package/dist/core/LambderResponseBuilder.d.ts +18 -14
- package/dist/core/LambderResponseBuilder.js +19 -25
- package/dist/core/LambderRouting.d.ts +18 -7
- package/dist/core/LambderRouting.js +17 -7
- package/dist/core/LambderTemplatingEngine.d.ts +0 -62
- package/dist/core/LambderTemplatingEngine.js +7 -3
- package/dist/index.d.ts +85 -32
- package/dist/index.js +44 -16
- package/dist/invoke/LambderInvokeCaller.d.ts +46 -139
- package/dist/invoke/LambderInvokeCaller.js +140 -335
- package/dist/invoke/LambderInvokeOutcome.d.ts +165 -0
- package/dist/invoke/LambderInvokeOutcome.js +129 -0
- package/dist/invoke/LambderLambdaEvent.d.ts +81 -0
- package/dist/invoke/LambderLambdaEvent.js +187 -0
- package/dist/invoke/lambderHandlerTransport.d.ts +36 -0
- package/dist/invoke/lambderHandlerTransport.js +89 -0
- package/dist/mock/LambderMockApp.d.ts +352 -0
- package/dist/mock/LambderMockApp.js +815 -0
- package/dist/mock/LambderMockBrowserCookies.d.ts +55 -0
- package/dist/mock/LambderMockBrowserCookies.js +76 -0
- package/dist/mock/LambderMockCallRecorder.d.ts +85 -0
- package/dist/mock/LambderMockCallRecorder.js +183 -0
- package/dist/mock/LambderMockCreateOptions.d.ts +161 -0
- package/dist/mock/LambderMockCreateOptions.js +9 -0
- package/dist/mock/LambderMockEntryRegistry.d.ts +52 -0
- package/dist/mock/LambderMockEntryRegistry.js +126 -0
- package/dist/mock/LambderMockFailureInjector.d.ts +60 -0
- package/dist/mock/LambderMockFailureInjector.js +138 -0
- package/dist/mock/LambderMockTypes.d.ts +421 -0
- package/dist/mock/LambderMockTypes.js +8 -0
- package/dist/mock/lambderMockConsoleLogger.d.ts +16 -0
- package/dist/mock/lambderMockConsoleLogger.js +35 -0
- package/dist/mock/lambderMockInvokeTransport.d.ts +50 -0
- package/dist/mock/lambderMockInvokeTransport.js +52 -0
- package/dist/mock/lambderMockMswHandler.d.ts +99 -0
- package/dist/mock/lambderMockMswHandler.js +126 -0
- package/dist/mock.d.ts +34 -0
- package/dist/mock.js +27 -0
- package/dist/session/LambderSessionController.d.ts +199 -30
- package/dist/session/LambderSessionController.js +396 -82
- package/dist/session/LambderSessionCrypto.d.ts +66 -0
- package/dist/session/LambderSessionCrypto.js +101 -0
- package/dist/session/LambderSessionManager.d.ts +118 -80
- package/dist/session/LambderSessionManager.js +212 -184
- package/dist/shared/LambderI18n.d.ts +6 -6
- package/dist/shared/LambderI18n.js +1 -1
- package/dist/shared/contracts/LambderFileSource.d.ts +33 -0
- package/dist/shared/contracts/LambderFileSource.js +19 -0
- package/dist/shared/contracts/LambderIdempotencyStore.d.ts +66 -0
- package/dist/shared/contracts/LambderIdempotencyStore.js +12 -0
- package/dist/shared/contracts/LambderRateLimiter.d.ts +71 -0
- package/dist/shared/contracts/LambderRateLimiter.js +24 -0
- package/dist/shared/contracts/LambderSessionStore.d.ts +72 -0
- package/dist/shared/contracts/LambderSessionStore.js +13 -0
- package/dist/shared/transport/LambderApiTransport.d.ts +139 -0
- package/dist/shared/transport/LambderApiTransport.js +65 -0
- package/dist/shared/transport/LambderCookieJar.d.ts +121 -0
- package/dist/shared/transport/LambderCookieJar.js +246 -0
- package/dist/shared/transport/lambderCookieJarTransport.d.ts +30 -0
- package/dist/shared/transport/lambderCookieJarTransport.js +60 -0
- package/dist/shared/util/LambderBase64.d.ts +10 -0
- package/dist/shared/util/LambderBase64.js +27 -0
- package/dist/shared/util/LambderCallAbort.d.ts +62 -0
- package/dist/shared/util/LambderCallAbort.js +80 -0
- package/dist/shared/util/LambderClientIp.d.ts +32 -0
- package/dist/shared/util/LambderClientIp.js +56 -0
- package/dist/shared/util/LambderExpiringMap.d.ts +119 -0
- package/dist/shared/util/LambderExpiringMap.js +217 -0
- package/dist/shared/util/LambderKeyFields.d.ts +32 -0
- package/dist/shared/util/LambderKeyFields.js +34 -0
- package/dist/shared/util/LambderNodeModules.d.ts +9 -0
- package/dist/shared/util/LambderNodeModules.js +39 -0
- package/dist/shared/util/LambderOptionChecks.d.ts +17 -0
- package/dist/shared/util/LambderOptionChecks.js +33 -0
- package/dist/shared/util/LambderResponseBrand.d.ts +20 -0
- package/dist/shared/util/LambderResponseBrand.js +18 -0
- package/dist/shared/util/LambderTextDigest.d.ts +17 -0
- package/dist/shared/util/LambderTextDigest.js +34 -0
- package/dist/shared/util/LambderTypeUtilities.d.ts +33 -0
- package/dist/shared/util/LambderTypeUtilities.js +8 -0
- package/dist/shared/wire/LambderAnswerHeaders.d.ts +60 -0
- package/dist/shared/wire/LambderAnswerHeaders.js +94 -0
- package/dist/shared/wire/LambderApiContract.d.ts +129 -0
- package/dist/shared/wire/LambderApiOptionValues.d.ts +39 -0
- package/dist/shared/wire/LambderApiOptionValues.js +11 -0
- package/dist/shared/wire/LambderApiOutcome.d.ts +128 -0
- package/dist/shared/{LambderApiOutcome.js → wire/LambderApiOutcome.js} +16 -9
- package/dist/shared/{LambderApiError.d.ts → wire/LambderApiRefusal.d.ts} +48 -26
- package/dist/shared/{LambderApiError.js → wire/LambderApiRefusal.js} +13 -11
- package/dist/shared/wire/LambderCallOptions.d.ts +171 -0
- package/dist/shared/wire/LambderCallOptions.js +17 -0
- package/dist/shared/{LambderCompressionCodec.d.ts → wire/LambderCompressionCodec.d.ts} +10 -6
- package/dist/shared/{LambderCompressionCodec.js → wire/LambderCompressionCodec.js} +67 -23
- package/dist/shared/{LambderCompressionOption.d.ts → wire/LambderCompressionOption.d.ts} +1 -1
- package/dist/shared/{LambderCompressionOption.js → wire/LambderCompressionOption.js} +3 -4
- package/dist/shared/{LambderCrashDetail.d.ts → wire/LambderCrashDetail.d.ts} +10 -0
- package/dist/shared/{LambderCrashDetail.js → wire/LambderCrashDetail.js} +30 -0
- package/dist/shared/wire/LambderHttpStatus.d.ts +12 -0
- package/dist/shared/wire/LambderHttpStatus.js +1 -0
- package/dist/shared/{LambderRequestPayload.d.ts → wire/LambderRequestPayload.d.ts} +25 -17
- package/dist/shared/{LambderRequestPayload.js → wire/LambderRequestPayload.js} +29 -52
- package/dist/shared/wire/LambderSessionCookieNames.d.ts +9 -0
- package/dist/shared/wire/LambderSessionCookieNames.js +9 -0
- package/dist/stores/LambderDdbCache.d.ts +12 -9
- package/dist/stores/LambderDdbCache.js +56 -47
- package/dist/stores/{LambderDdbIdempotency.d.ts → LambderDdbIdempotencyStore.d.ts} +41 -31
- package/dist/stores/LambderDdbIdempotencyStore.js +319 -0
- package/dist/stores/LambderDdbRateLimiter.d.ts +30 -49
- package/dist/stores/LambderDdbRateLimiter.js +47 -45
- package/dist/stores/LambderDdbSdk.d.ts +83 -6
- package/dist/stores/LambderDdbSdk.js +83 -2
- package/dist/stores/LambderDdbSessionStore.d.ts +65 -0
- package/dist/stores/LambderDdbSessionStore.js +161 -0
- package/dist/stores/LambderHttpFileSource.d.ts +1 -1
- package/dist/stores/LambderHttpFileSource.js +10 -1
- package/dist/stores/LambderLocalFileSource.d.ts +15 -0
- package/dist/stores/LambderLocalFileSource.js +28 -0
- package/dist/stores/LambderMemoryIdempotencyStore.d.ts +63 -0
- package/dist/stores/LambderMemoryIdempotencyStore.js +113 -0
- package/dist/stores/LambderMemoryRateLimiter.d.ts +34 -0
- package/dist/stores/LambderMemoryRateLimiter.js +64 -0
- package/dist/stores/LambderMemorySessionStore.d.ts +48 -0
- package/dist/stores/LambderMemorySessionStore.js +74 -0
- package/dist/stores/LambderS3FileSource.d.ts +1 -1
- package/dist/stores/LambderS3FileSource.js +1 -1
- package/package.json +26 -24
- package/dist/client/LambderMSW.d.ts +0 -69
- package/dist/client/LambderMSW.js +0 -121
- package/dist/policies/LambderApiGuards.d.ts +0 -256
- package/dist/policies/LambderApiGuards.js +0 -94
- package/dist/policies/LambderApiIdempotency.d.ts +0 -58
- package/dist/policies/LambderApiIdempotency.js +0 -219
- package/dist/policies/LambderApiPolicies.d.ts +0 -42
- package/dist/policies/LambderApiPolicies.js +0 -52
- package/dist/policies/LambderApiRateLimits.d.ts +0 -132
- package/dist/policies/LambderApiRateLimits.js +0 -119
- package/dist/shared/LambderApiContract.d.ts +0 -57
- package/dist/shared/LambderApiOutcome.d.ts +0 -69
- package/dist/shared/LambderCallOptions.d.ts +0 -71
- package/dist/shared/LambderCallOptions.js +0 -16
- package/dist/shared/node-polyfills.d.ts +0 -4
- package/dist/shared/node-polyfills.js +0 -58
- package/dist/stores/LambderDdbIdempotency.js +0 -229
- package/dist/testing.d.ts +0 -9
- package/dist/testing.js +0 -8
- /package/dist/shared/{LambderApiContract.js → wire/LambderApiContract.js} +0 -0
- /package/dist/{core → shared/wire}/LambderCookie.d.ts +0 -0
- /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
|
+
}
|