lambder 7.2.5 → 8.0.2
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 +1021 -3
- package/README.md +43 -21
- package/dist/api/LambderApiAnswer.d.ts +18 -22
- package/dist/api/LambderApiAnswer.js +6 -7
- package/dist/api/LambderApiCallContext.d.ts +21 -8
- package/dist/api/LambderApiCallContext.js +22 -4
- package/dist/api/LambderApiDefinition.d.ts +4 -3
- package/dist/api/LambderApiEnvelope.d.ts +14 -9
- package/dist/api/LambderApiEnvelope.js +33 -34
- package/dist/api/LambderApiGuards.d.ts +78 -51
- package/dist/api/LambderApiGuards.js +34 -36
- package/dist/api/LambderApiIdempotency.d.ts +74 -61
- package/dist/api/LambderApiIdempotency.js +226 -151
- package/dist/api/LambderApiOutputValidationError.d.ts +32 -0
- package/dist/api/LambderApiOutputValidationError.js +50 -0
- package/dist/api/LambderApiPipeline.d.ts +77 -39
- package/dist/api/LambderApiPipeline.js +135 -62
- package/dist/api/LambderApiRateLimits.d.ts +208 -54
- package/dist/api/LambderApiRateLimits.js +197 -108
- package/dist/api/LambderApiRequest.d.ts +27 -21
- package/dist/api/LambderApiRequest.js +26 -19
- package/dist/api/LambderApiSignature.d.ts +12 -15
- package/dist/api/LambderApiSignature.js +28 -51
- package/dist/api/LambderApiValidationRefusal.d.ts +9 -9
- package/dist/api/LambderApiValidationRefusal.js +10 -10
- package/dist/build/freshProcessVerifier.d.ts +13 -0
- package/dist/build/freshProcessVerifier.js +19 -0
- package/dist/build/writeApiSignatures.d.ts +109 -0
- package/dist/build/writeApiSignatures.js +222 -0
- package/dist/build.d.ts +9 -0
- package/dist/build.js +8 -0
- package/dist/client/LambderCaller.d.ts +13 -44
- package/dist/client/LambderCaller.js +77 -84
- package/dist/client/LambderReloadLoopBreaker.d.ts +56 -26
- package/dist/client/LambderReloadLoopBreaker.js +90 -46
- package/dist/client/lambderFetchTransport.d.ts +4 -1
- package/dist/client/lambderFetchTransport.js +52 -28
- package/dist/client.d.ts +5 -3
- package/dist/client.js +2 -1
- package/dist/core/Lambder.d.ts +161 -69
- package/dist/core/Lambder.js +370 -226
- package/dist/core/LambderContext.d.ts +82 -15
- package/dist/core/LambderContext.js +107 -20
- package/dist/core/LambderCors.d.ts +21 -3
- package/dist/core/LambderCors.js +35 -16
- package/dist/core/LambderCrashHandling.d.ts +40 -0
- package/dist/core/LambderCrashHandling.js +97 -0
- package/dist/core/LambderCreateOptions.d.ts +151 -75
- package/dist/core/LambderCreateOptions.js +16 -23
- package/dist/core/LambderFiles.d.ts +28 -7
- package/dist/core/LambderFiles.js +73 -33
- package/dist/core/LambderIndexHtml.js +12 -11
- package/dist/core/LambderPolicyBuilders.d.ts +17 -5
- package/dist/core/LambderPolicyBuilders.js +17 -5
- package/dist/core/LambderPublicFiles.d.ts +11 -5
- package/dist/core/LambderPublicFiles.js +32 -4
- package/dist/core/LambderRequestPath.d.ts +43 -0
- package/dist/core/LambderRequestPath.js +63 -0
- package/dist/core/LambderResponse.d.ts +26 -5
- package/dist/core/LambderResponse.js +157 -70
- package/dist/core/LambderResponseBuilder.d.ts +49 -4
- package/dist/core/LambderResponseBuilder.js +64 -3
- package/dist/core/LambderRouting.d.ts +2 -3
- package/dist/core/LambderRouting.js +22 -7
- package/dist/core/LambderTemplatingEngine.js +211 -32
- package/dist/index.d.ts +15 -8
- package/dist/index.js +5 -4
- package/dist/invoke/LambderInvokeCaller.d.ts +37 -42
- package/dist/invoke/LambderInvokeCaller.js +76 -66
- package/dist/invoke/LambderInvokeOutcome.d.ts +27 -26
- package/dist/invoke/LambderInvokeOutcome.js +9 -22
- package/dist/invoke/LambderLambdaEvent.d.ts +44 -10
- package/dist/invoke/LambderLambdaEvent.js +80 -37
- package/dist/invoke/lambderHandlerTransport.d.ts +12 -10
- package/dist/invoke/lambderHandlerTransport.js +16 -19
- package/dist/mock/LambderMockApp.d.ts +67 -83
- package/dist/mock/LambderMockApp.js +167 -153
- package/dist/mock/LambderMockBrowserCookies.d.ts +24 -28
- package/dist/mock/LambderMockBrowserCookies.js +24 -28
- package/dist/mock/LambderMockCallRecorder.d.ts +15 -22
- package/dist/mock/LambderMockCallRecorder.js +19 -28
- package/dist/mock/LambderMockCreateOptions.d.ts +42 -24
- package/dist/mock/LambderMockEntryRegistry.d.ts +11 -12
- package/dist/mock/LambderMockEntryRegistry.js +24 -29
- package/dist/mock/LambderMockFailureInjector.d.ts +3 -6
- package/dist/mock/LambderMockFailureInjector.js +3 -6
- package/dist/mock/LambderMockTypes.d.ts +78 -108
- package/dist/mock/lambderMockInvokeTransport.d.ts +11 -13
- package/dist/mock/lambderMockInvokeTransport.js +11 -10
- package/dist/mock/lambderMockMswHandler.d.ts +33 -29
- package/dist/mock/lambderMockMswHandler.js +50 -39
- package/dist/mock.d.ts +3 -1
- package/dist/mock.js +5 -3
- package/dist/session/LambderSessionController.d.ts +108 -89
- package/dist/session/LambderSessionController.js +187 -168
- package/dist/session/LambderSessionCrypto.d.ts +16 -7
- package/dist/session/LambderSessionCrypto.js +26 -12
- package/dist/session/LambderSessionManager.d.ts +136 -47
- package/dist/session/LambderSessionManager.js +280 -139
- package/dist/shared/LambderHtml.d.ts +42 -3
- package/dist/shared/LambderHtml.js +127 -7
- package/dist/shared/LambderHtmlPositions.d.ts +173 -0
- package/dist/shared/LambderHtmlPositions.js +652 -0
- package/dist/shared/LambderI18n.d.ts +10 -11
- package/dist/shared/LambderI18n.js +33 -21
- package/dist/shared/contracts/LambderCache.d.ts +66 -0
- package/dist/shared/contracts/LambderCache.js +11 -0
- package/dist/shared/contracts/LambderFileSource.d.ts +6 -6
- package/dist/shared/contracts/LambderFileSource.js +5 -8
- package/dist/shared/contracts/LambderIdempotencyStore.d.ts +51 -22
- package/dist/shared/contracts/LambderIdempotencyStore.js +4 -5
- package/dist/shared/contracts/LambderRateLimiter.d.ts +27 -15
- package/dist/shared/contracts/LambderRateLimiter.js +4 -5
- package/dist/shared/contracts/LambderSessionStore.d.ts +65 -26
- package/dist/shared/contracts/LambderSessionStore.js +5 -6
- package/dist/shared/transport/LambderApiTransport.d.ts +27 -27
- package/dist/shared/transport/LambderApiTransport.js +7 -7
- package/dist/shared/transport/LambderCookieJar.d.ts +28 -35
- package/dist/shared/transport/LambderCookieJar.js +54 -66
- package/dist/shared/transport/lambderCookieJarTransport.d.ts +11 -13
- package/dist/shared/transport/lambderCookieJarTransport.js +24 -23
- package/dist/shared/util/LambderCallAbort.d.ts +5 -5
- package/dist/shared/util/LambderCallAbort.js +5 -5
- package/dist/shared/util/LambderClientIp.d.ts +27 -11
- package/dist/shared/util/LambderClientIp.js +96 -13
- package/dist/shared/util/LambderExpiringMap.d.ts +35 -49
- package/dist/shared/util/LambderExpiringMap.js +41 -57
- package/dist/shared/util/LambderNodeModules.js +6 -7
- package/dist/shared/util/LambderOptionChecks.d.ts +4 -4
- package/dist/shared/util/LambderOptionChecks.js +4 -4
- package/dist/shared/util/LambderResponseBrand.d.ts +5 -5
- package/dist/shared/util/LambderResponseBrand.js +5 -5
- package/dist/shared/util/LambderTestingDoors.d.ts +29 -0
- package/dist/shared/util/LambderTestingDoors.js +29 -0
- package/dist/shared/util/LambderTypeUtilities.d.ts +7 -8
- package/dist/shared/util/LambderTypeUtilities.js +3 -3
- package/dist/shared/util/boundKeyField.d.ts +20 -0
- package/dist/shared/util/boundKeyField.js +34 -0
- package/dist/shared/util/canonicalJson.d.ts +11 -0
- package/dist/shared/util/canonicalJson.js +28 -0
- package/dist/shared/util/joinKeyFields.d.ts +20 -0
- package/dist/shared/util/joinKeyFields.js +22 -0
- package/dist/shared/wire/LambderAnswerHeaders.d.ts +12 -16
- package/dist/shared/wire/LambderAnswerHeaders.js +12 -16
- package/dist/shared/wire/LambderApiContract.d.ts +107 -32
- package/dist/shared/wire/LambderApiOutcome.d.ts +43 -31
- package/dist/shared/wire/LambderApiOutcome.js +48 -23
- package/dist/shared/wire/LambderApiRefusal.d.ts +39 -27
- package/dist/shared/wire/LambderApiRefusal.js +36 -7
- package/dist/shared/wire/LambderApiSignature.d.ts +18 -22
- package/dist/shared/wire/LambderApiSignature.js +16 -19
- package/dist/shared/wire/LambderCallOptions.d.ts +38 -47
- package/dist/shared/wire/LambderCallOptions.js +9 -11
- package/dist/shared/wire/LambderCompressionCodec.d.ts +29 -34
- package/dist/shared/wire/LambderCompressionCodec.js +31 -36
- package/dist/shared/wire/LambderCompressionOption.d.ts +9 -9
- package/dist/shared/wire/LambderCompressionOption.js +9 -9
- package/dist/shared/wire/LambderCrashDetail.d.ts +12 -15
- package/dist/shared/wire/LambderCrashDetail.js +12 -15
- package/dist/shared/wire/LambderDefaultApiPath.d.ts +6 -0
- package/dist/shared/wire/LambderDefaultApiPath.js +6 -0
- package/dist/shared/wire/LambderHttpStatus.d.ts +6 -7
- package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +89 -0
- package/dist/shared/wire/LambderIdempotencyKeyScope.js +146 -0
- package/dist/shared/wire/LambderInvokeApiId.d.ts +27 -0
- package/dist/shared/wire/LambderInvokeApiId.js +27 -0
- package/dist/shared/wire/LambderOutcomeAssertions.d.ts +79 -0
- package/dist/shared/wire/LambderOutcomeAssertions.js +112 -0
- package/dist/shared/wire/LambderRequestPayload.d.ts +18 -20
- package/dist/shared/wire/LambderRequestPayload.js +4 -6
- package/dist/stores/LambderCacheFiller.d.ts +48 -0
- package/dist/stores/LambderCacheFiller.js +119 -0
- package/dist/stores/LambderCacheKeys.d.ts +26 -0
- package/dist/stores/LambderCacheKeys.js +54 -0
- package/dist/stores/LambderCacheValues.d.ts +45 -0
- package/dist/stores/LambderCacheValues.js +74 -0
- package/dist/stores/LambderDdbCache.d.ts +121 -56
- package/dist/stores/LambderDdbCache.js +528 -225
- package/dist/stores/LambderDdbIdempotencyStore.d.ts +33 -22
- package/dist/stores/LambderDdbIdempotencyStore.js +75 -50
- package/dist/stores/LambderDdbRateLimiter.d.ts +76 -20
- package/dist/stores/LambderDdbRateLimiter.js +151 -39
- package/dist/stores/LambderDdbSdk.d.ts +43 -31
- package/dist/stores/LambderDdbSdk.js +79 -33
- package/dist/stores/LambderDdbSessionStore.d.ts +27 -14
- package/dist/stores/LambderDdbSessionStore.js +119 -47
- package/dist/stores/LambderHttpFileSource.d.ts +15 -6
- package/dist/stores/LambderHttpFileSource.js +15 -13
- package/dist/stores/LambderMemoryCache.d.ts +49 -0
- package/dist/stores/LambderMemoryCache.js +113 -0
- package/dist/stores/LambderMemoryIdempotencyStore.d.ts +13 -12
- package/dist/stores/LambderMemoryIdempotencyStore.js +31 -30
- package/dist/stores/LambderMemoryRateLimiter.d.ts +8 -9
- package/dist/stores/LambderMemoryRateLimiter.js +14 -13
- package/dist/stores/LambderMemorySessionStore.d.ts +14 -11
- package/dist/stores/LambderMemorySessionStore.js +38 -19
- package/dist/stores/LambderS3FileSource.d.ts +21 -6
- package/dist/stores/LambderS3FileSource.js +12 -7
- package/dist/testing/LambderTestApp.d.ts +176 -0
- package/dist/testing/LambderTestApp.js +204 -0
- package/dist/testing/LambderTestVisitor.d.ts +153 -0
- package/dist/testing/LambderTestVisitor.js +154 -0
- package/dist/testing.d.ts +27 -0
- package/dist/testing.js +24 -0
- package/package.json +20 -3
- package/dist/api/LambderApiPolicyEngine.d.ts +0 -36
- package/dist/api/LambderApiPolicyEngine.js +0 -77
- package/dist/shared/util/LambderKeyFields.d.ts +0 -32
- package/dist/shared/util/LambderKeyFields.js +0 -34
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { LAMBDER_REFUSAL_CODES } from "./LambderApiRefusal.js";
|
|
2
|
+
/** The outcome of an attempt that ended before anything was sent: it tried nothing. */
|
|
3
|
+
export const IDEMPOTENT_ATTEMPT_NOT_SENT = { ok: false, reason: "notSent" };
|
|
4
|
+
/**
|
|
5
|
+
* Refusals that answer this request itself, so the same request would get
|
|
6
|
+
* the same answer again and what the person sends next is a new operation.
|
|
7
|
+
*/
|
|
8
|
+
const REFUSAL_REASONS = new Set(["validation", "notAuthorized", "errorMessage"]);
|
|
9
|
+
/** Answers that never reached the operation: the same request may pass later. */
|
|
10
|
+
const UNTRIED_REASONS = new Set(["sessionExpired", "versionExpired", "payloadTooLarge", "notSent"]);
|
|
11
|
+
/**
|
|
12
|
+
* Generate an idempotency key for one logical operation. Create it when the
|
|
13
|
+
* operation begins (a form opens, a draft starts), send the same key on every
|
|
14
|
+
* attempt of that operation, and generate a new one after a confirmed
|
|
15
|
+
* success; createIdempotencyKeyScope() does that bookkeeping itself. Uses
|
|
16
|
+
* crypto.randomUUID when available, else a v4 UUID from getRandomValues,
|
|
17
|
+
* because randomUUID only exists in secure contexts (plain-http LAN device
|
|
18
|
+
* testing lacks it).
|
|
19
|
+
*
|
|
20
|
+
* A runtime with neither throws rather than using Math.random: the key
|
|
21
|
+
* scopes the replay record for a logged-out client, so a guessable one hands
|
|
22
|
+
* that client's stored response to whoever guesses it.
|
|
23
|
+
*/
|
|
24
|
+
export const createIdempotencyKey = () => {
|
|
25
|
+
const cryptoObj = globalThis.crypto;
|
|
26
|
+
if (cryptoObj?.randomUUID)
|
|
27
|
+
return cryptoObj.randomUUID();
|
|
28
|
+
if (!cryptoObj?.getRandomValues)
|
|
29
|
+
throw new Error("createIdempotencyKey needs crypto.getRandomValues: an idempotency key must be unguessable, and this runtime offers no random source that is.");
|
|
30
|
+
const bytes = new Uint8Array(16);
|
|
31
|
+
cryptoObj.getRandomValues(bytes);
|
|
32
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
33
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
34
|
+
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
35
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
36
|
+
};
|
|
37
|
+
/** What a scope's attempts are started through: the class hands it out once, below, so it is reachable from this module alone. */
|
|
38
|
+
let beginAttemptOf;
|
|
39
|
+
/**
|
|
40
|
+
* One logical operation's rotating idempotency key, from
|
|
41
|
+
* createIdempotencyKeyScope(). Every attempt of the operation
|
|
42
|
+
* sends the current key, and the scope moves to a new key once an answer
|
|
43
|
+
* settles the operation:
|
|
44
|
+
*
|
|
45
|
+
* - A success settles it, and so does a key refused as reused for another
|
|
46
|
+
* request, since that key can never carry this one.
|
|
47
|
+
* - A refusal of this request (a rejected input, not authorized, an
|
|
48
|
+
* errorMessage) settles it, unless another attempt under the same key is
|
|
49
|
+
* still in flight or went unanswered. That attempt may run or have run the
|
|
50
|
+
* operation, and guards, validation and rate limits refuse before the
|
|
51
|
+
* replay record is claimed, so the refusal of a retry or a double-tap says
|
|
52
|
+
* nothing about it. Keeping the key lets the next attempt replay the
|
|
53
|
+
* original's answer rather than run again.
|
|
54
|
+
* - A rate limit, an expired session, a stale version, and an attempt that
|
|
55
|
+
* ended before anything was sent keep the key.
|
|
56
|
+
* - Anything that is not an answer (a network failure, a timeout, a 5xx, a
|
|
57
|
+
* crash) keeps the key and marks it as possibly used, and so does a
|
|
58
|
+
* duplicate of an original still in flight, unless the scope has another
|
|
59
|
+
* attempt of its own still waiting for its answer (a double-tap): that
|
|
60
|
+
* attempt is the original, and its answer settles the key, a refusal
|
|
61
|
+
* included.
|
|
62
|
+
*
|
|
63
|
+
* An answer to a key the scope has already moved past changes nothing: a
|
|
64
|
+
* slow original answering after the person moved on must not rotate away
|
|
65
|
+
* the key their current attempt is using.
|
|
66
|
+
*/
|
|
67
|
+
export class LambderIdempotencyKeyScope {
|
|
68
|
+
#key = createIdempotencyKey();
|
|
69
|
+
#possiblyUsed = false;
|
|
70
|
+
/** Attempts under the current key that have not settled yet. */
|
|
71
|
+
#inFlight = 0;
|
|
72
|
+
static {
|
|
73
|
+
beginAttemptOf = (scope) => scope.#beginAttempt();
|
|
74
|
+
}
|
|
75
|
+
/** The key for the operation currently in progress. */
|
|
76
|
+
get current() { return this.#key; }
|
|
77
|
+
/** Moves on to a new operation by hand, for a caller that settles operations itself. Returns the new key. */
|
|
78
|
+
rotate() {
|
|
79
|
+
this.#key = createIdempotencyKey();
|
|
80
|
+
this.#possiblyUsed = false;
|
|
81
|
+
this.#inFlight = 0;
|
|
82
|
+
return this.#key;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Starts one attempt under the current key; the callers do this for
|
|
86
|
+
* every call handed the scope. Private, and reached through
|
|
87
|
+
* beginIdempotentAttempt: an attempt started and never settled holds the
|
|
88
|
+
* in-flight count up, so a refusal would stop moving the key.
|
|
89
|
+
*/
|
|
90
|
+
#beginAttempt() {
|
|
91
|
+
const key = this.#key;
|
|
92
|
+
this.#inFlight += 1;
|
|
93
|
+
let settled = false;
|
|
94
|
+
return { key, settle: (outcome) => {
|
|
95
|
+
if (settled)
|
|
96
|
+
return;
|
|
97
|
+
settled = true;
|
|
98
|
+
if (key !== this.#key)
|
|
99
|
+
return;
|
|
100
|
+
this.#inFlight -= 1;
|
|
101
|
+
const code = outcome.errorMessage?.code;
|
|
102
|
+
if (outcome.ok || code === LAMBDER_REFUSAL_CODES.idempotencyKeyReused) {
|
|
103
|
+
this.rotate();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// A rate limit refuses before the claim, whatever code a policy's
|
|
107
|
+
// own message carries, so its status is what names it.
|
|
108
|
+
if (UNTRIED_REASONS.has(outcome.reason ?? "") || outcome.status === 429 || code === LAMBDER_REFUSAL_CODES.rateLimited)
|
|
109
|
+
return;
|
|
110
|
+
if (REFUSAL_REASONS.has(outcome.reason ?? "") && code !== LAMBDER_REFUSAL_CODES.duplicateInFlight) {
|
|
111
|
+
if (!this.#possiblyUsed && this.#inFlight === 0)
|
|
112
|
+
this.rotate();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// A duplicate while another attempt of this scope still waits for
|
|
116
|
+
// its answer: unless an earlier attempt went unanswered (which
|
|
117
|
+
// marked the key already), the running original is that attempt,
|
|
118
|
+
// and its own answer settles the key. Marked here, the key would
|
|
119
|
+
// outlive that answer when it is a refusal, and the corrected
|
|
120
|
+
// request after it would be refused as a reused key.
|
|
121
|
+
if (code === LAMBDER_REFUSAL_CODES.duplicateInFlight && this.#inFlight > 0)
|
|
122
|
+
return;
|
|
123
|
+
// No answer, or an original this scope has no attempt waiting
|
|
124
|
+
// for: the operation may have run under this key.
|
|
125
|
+
this.#possiblyUsed = true;
|
|
126
|
+
} };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* A self-rotating idempotency key for a component or form that performs the
|
|
131
|
+
* same logical operation repeatedly. Pass the scope itself as the call's
|
|
132
|
+
* `idempotencyKey`, on LambderCaller or LambderInvokeCaller: every attempt of
|
|
133
|
+
* one operation (a retry after a dropped connection, a double-tap) sends its
|
|
134
|
+
* current key, so the server collapses them, and the caller rotates it once
|
|
135
|
+
* an answer settles the operation (a success, or a refusal of this request),
|
|
136
|
+
* so the next attempt, a corrected form included, is a new operation.
|
|
137
|
+
* LambderIdempotencyKeyScope says which answers settle it.
|
|
138
|
+
*
|
|
139
|
+
* ```typescript
|
|
140
|
+
* const submitKey = createIdempotencyKeyScope();
|
|
141
|
+
* await caller.api("order.create", payload, { idempotencyKey: submitKey });
|
|
142
|
+
* ```
|
|
143
|
+
*/
|
|
144
|
+
export const createIdempotencyKeyScope = () => new LambderIdempotencyKeyScope();
|
|
145
|
+
/** The attempt a call makes with its idempotencyKey option: a scope's current key, or a plain key with nothing to settle. */
|
|
146
|
+
export const beginIdempotentAttempt = (option) => option instanceof LambderIdempotencyKeyScope ? beginAttemptOf(option) : { key: option, settle: () => { } };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `requestContext.apiId` of an event LambderInvokeCaller synthesizes, and
|
|
3
|
+
* what the server reads to tell a direct invoke from a gateway's request.
|
|
4
|
+
*
|
|
5
|
+
* A gateway writes its own id there: API Gateway and a Function URL both
|
|
6
|
+
* generate theirs as lowercase letters and digits, so no request through one
|
|
7
|
+
* arrives carrying this hyphenated value, whatever headers it sends. Only a
|
|
8
|
+
* direct invoke delivers an event whose sender wrote the id, and a direct
|
|
9
|
+
* invoke is authorized by IAM. Declared in shared because the invoke caller
|
|
10
|
+
* writes it and the server reads it, and the two must agree byte for byte.
|
|
11
|
+
*/
|
|
12
|
+
export declare const LAMBDER_INVOKE_API_ID = "lambder-invoke";
|
|
13
|
+
/**
|
|
14
|
+
* The `requestContext.apiId` of a browser-shaped event Lambder synthesizes
|
|
15
|
+
* (lambder/testing, lambderHandlerTransport): a request standing for one a
|
|
16
|
+
* gateway delivered, not an invoke. Hyphenated like LAMBDER_INVOKE_API_ID,
|
|
17
|
+
* so no gateway's event carries it either.
|
|
18
|
+
*
|
|
19
|
+
* The server reads it, like the invoke id, as "Lambder's own event builder
|
|
20
|
+
* wrote this": that builder always delivers a 2.0 event's path decoded, so
|
|
21
|
+
* the path is not decoded a second time whatever host the request names, a
|
|
22
|
+
* Function URL's included. A direct invoker that writes it gains nothing: it
|
|
23
|
+
* writes the whole event, the path included, either way. Declared beside the
|
|
24
|
+
* invoke id for the same reason: the builder writes it and the server reads
|
|
25
|
+
* it.
|
|
26
|
+
*/
|
|
27
|
+
export declare const LAMBDER_LOCAL_API_ID = "lambder-local";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `requestContext.apiId` of an event LambderInvokeCaller synthesizes, and
|
|
3
|
+
* what the server reads to tell a direct invoke from a gateway's request.
|
|
4
|
+
*
|
|
5
|
+
* A gateway writes its own id there: API Gateway and a Function URL both
|
|
6
|
+
* generate theirs as lowercase letters and digits, so no request through one
|
|
7
|
+
* arrives carrying this hyphenated value, whatever headers it sends. Only a
|
|
8
|
+
* direct invoke delivers an event whose sender wrote the id, and a direct
|
|
9
|
+
* invoke is authorized by IAM. Declared in shared because the invoke caller
|
|
10
|
+
* writes it and the server reads it, and the two must agree byte for byte.
|
|
11
|
+
*/
|
|
12
|
+
export const LAMBDER_INVOKE_API_ID = "lambder-invoke";
|
|
13
|
+
/**
|
|
14
|
+
* The `requestContext.apiId` of a browser-shaped event Lambder synthesizes
|
|
15
|
+
* (lambder/testing, lambderHandlerTransport): a request standing for one a
|
|
16
|
+
* gateway delivered, not an invoke. Hyphenated like LAMBDER_INVOKE_API_ID,
|
|
17
|
+
* so no gateway's event carries it either.
|
|
18
|
+
*
|
|
19
|
+
* The server reads it, like the invoke id, as "Lambder's own event builder
|
|
20
|
+
* wrote this": that builder always delivers a 2.0 event's path decoded, so
|
|
21
|
+
* the path is not decoded a second time whatever host the request names, a
|
|
22
|
+
* Function URL's included. A direct invoker that writes it gains nothing: it
|
|
23
|
+
* writes the whole event, the path included, either way. Declared beside the
|
|
24
|
+
* invoke id for the same reason: the builder writes it and the server reads
|
|
25
|
+
* it.
|
|
26
|
+
*/
|
|
27
|
+
export const LAMBDER_LOCAL_API_ID = "lambder-local";
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two assertions over a call's outcome, for tests.
|
|
3
|
+
*
|
|
4
|
+
* An outcome is a discriminated union, so a test that expects a refusal must
|
|
5
|
+
* narrow before it can read what the refusal carries: check `ok`, branch on
|
|
6
|
+
* it, check `reason`. Written by hand, the failing case prints "expected
|
|
7
|
+
* false to be true" and says nothing about what came back, the one thing
|
|
8
|
+
* worth knowing when a call that should have been refused went through, or
|
|
9
|
+
* crashed instead.
|
|
10
|
+
*
|
|
11
|
+
* These narrow through an `asserts` signature, so the lines after one read
|
|
12
|
+
* the arm it proved, and they throw a plain Error naming what the outcome
|
|
13
|
+
* was. No test runner is imported: the same two functions serve vitest, jest
|
|
14
|
+
* and node:test, from `lambder/testing` over a real server and from
|
|
15
|
+
* `lambder/mock` over a mock one. Pure and dependency-free, like the outcome
|
|
16
|
+
* vocabulary they read.
|
|
17
|
+
*
|
|
18
|
+
* Typed structurally over `ok` and `reason` rather than over
|
|
19
|
+
* LambderApiOutcome, so a LambderInvokeOutcome, whose failure side names
|
|
20
|
+
* other reasons, is narrowed by the same functions and a misspelled reason is
|
|
21
|
+
* a compile error against whichever union was passed.
|
|
22
|
+
*/
|
|
23
|
+
/** What both callers' outcomes have in common: the discriminant, and a reason on the failure side. */
|
|
24
|
+
type LambderOutcomeShape = {
|
|
25
|
+
ok: true;
|
|
26
|
+
} | {
|
|
27
|
+
ok: false;
|
|
28
|
+
reason: string;
|
|
29
|
+
};
|
|
30
|
+
/** Every reason the failure side of an outcome union can carry. */
|
|
31
|
+
type LambderFailureReasonOf<TOutcome> = TOutcome extends {
|
|
32
|
+
ok: false;
|
|
33
|
+
reason: infer TReason;
|
|
34
|
+
} ? TReason : never;
|
|
35
|
+
/**
|
|
36
|
+
* The failure arms that can carry one of the given reasons, each narrowed to
|
|
37
|
+
* it. Per arm rather than through Extract: one arm may carry several reasons
|
|
38
|
+
* (`network`, `timeout`, `server` and `unknown` share theirs), and Extract
|
|
39
|
+
* would drop that arm for any single one of them.
|
|
40
|
+
*/
|
|
41
|
+
type LambderFailureWithReason<TOutcome, TReason> = TOutcome extends {
|
|
42
|
+
ok: false;
|
|
43
|
+
reason: infer TArmReason;
|
|
44
|
+
} ? [TReason & TArmReason] extends [never] ? never : TOutcome & {
|
|
45
|
+
reason: TReason & TArmReason;
|
|
46
|
+
} : never;
|
|
47
|
+
/** What else a failure is expected to carry, beside its reason. */
|
|
48
|
+
export type LambderExpectedFailure = {
|
|
49
|
+
/** The refusal's machine-readable code (`errorMessage.code`), e.g. a LAMBDER_REFUSAL_CODES value or the app's own. */
|
|
50
|
+
code?: string;
|
|
51
|
+
/** The HTTP status the answer came with. */
|
|
52
|
+
status?: number;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Asserts that a call succeeded, and narrows the outcome to its success arm,
|
|
56
|
+
* so `outcome.payload` reads directly on the next line.
|
|
57
|
+
*
|
|
58
|
+
* ```typescript
|
|
59
|
+
* const outcome = await visitor.apiOutcome("order.create", { sku });
|
|
60
|
+
* assertApiSuccess(outcome);
|
|
61
|
+
* expect(outcome.payload?.orderId).toBeDefined();
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
export declare function assertApiSuccess<TOutcome extends LambderOutcomeShape>(outcome: TOutcome): asserts outcome is Extract<TOutcome, {
|
|
65
|
+
ok: true;
|
|
66
|
+
}>;
|
|
67
|
+
/**
|
|
68
|
+
* Asserts that a call failed, with the given reason when one is named, and
|
|
69
|
+
* narrows the outcome to the arms that reason can be, so what it carries
|
|
70
|
+
* (`zodError` after "validation", `response` after an envelope reason,
|
|
71
|
+
* `error` after the rest) reads directly on the next line.
|
|
72
|
+
*
|
|
73
|
+
* ```typescript
|
|
74
|
+
* assertApiFailure(await member.apiOutcome("org.delete", { id }), "notAuthorized");
|
|
75
|
+
* assertApiFailure(await guest.apiOutcome("signup", form), "errorMessage", { code: LAMBDER_REFUSAL_CODES.rateLimited, status: 429 });
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
export declare function assertApiFailure<TOutcome extends LambderOutcomeShape, TReason extends LambderFailureReasonOf<TOutcome> = LambderFailureReasonOf<TOutcome>>(outcome: TOutcome, reason?: TReason, expected?: LambderExpectedFailure): asserts outcome is LambderFailureWithReason<TOutcome, TReason>;
|
|
79
|
+
export {};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two assertions over a call's outcome, for tests.
|
|
3
|
+
*
|
|
4
|
+
* An outcome is a discriminated union, so a test that expects a refusal must
|
|
5
|
+
* narrow before it can read what the refusal carries: check `ok`, branch on
|
|
6
|
+
* it, check `reason`. Written by hand, the failing case prints "expected
|
|
7
|
+
* false to be true" and says nothing about what came back, the one thing
|
|
8
|
+
* worth knowing when a call that should have been refused went through, or
|
|
9
|
+
* crashed instead.
|
|
10
|
+
*
|
|
11
|
+
* These narrow through an `asserts` signature, so the lines after one read
|
|
12
|
+
* the arm it proved, and they throw a plain Error naming what the outcome
|
|
13
|
+
* was. No test runner is imported: the same two functions serve vitest, jest
|
|
14
|
+
* and node:test, from `lambder/testing` over a real server and from
|
|
15
|
+
* `lambder/mock` over a mock one. Pure and dependency-free, like the outcome
|
|
16
|
+
* vocabulary they read.
|
|
17
|
+
*
|
|
18
|
+
* Typed structurally over `ok` and `reason` rather than over
|
|
19
|
+
* LambderApiOutcome, so a LambderInvokeOutcome, whose failure side names
|
|
20
|
+
* other reasons, is narrowed by the same functions and a misspelled reason is
|
|
21
|
+
* a compile error against whichever union was passed.
|
|
22
|
+
*/
|
|
23
|
+
const MAX_DESCRIBED_VALUE_LENGTH = 300;
|
|
24
|
+
/** A value as it can be printed in an assertion message: JSON, cut short, never throwing over a cyclic one. */
|
|
25
|
+
const describeValue = (value) => {
|
|
26
|
+
let text;
|
|
27
|
+
try {
|
|
28
|
+
text = JSON.stringify(value) ?? String(value);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
text = String(value);
|
|
32
|
+
}
|
|
33
|
+
return text.length > MAX_DESCRIBED_VALUE_LENGTH ? `${text.slice(0, MAX_DESCRIBED_VALUE_LENGTH)}...` : text;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* The error a failure carries, which becomes the cause of the assertion's
|
|
37
|
+
* own: a test runner prints the chain, so an app that crashed under
|
|
38
|
+
* `lambder/testing` shows the handler's stack under the failed assertion.
|
|
39
|
+
*/
|
|
40
|
+
const errorOf = (outcome) => {
|
|
41
|
+
const error = outcome.error;
|
|
42
|
+
return error instanceof Error ? error : undefined;
|
|
43
|
+
};
|
|
44
|
+
/** One line saying what an outcome was, for the message of an assertion it failed. */
|
|
45
|
+
const describeOutcome = (outcome) => {
|
|
46
|
+
if (outcome.ok)
|
|
47
|
+
return `a success carrying ${describeValue(outcome.payload)}`;
|
|
48
|
+
const failure = outcome;
|
|
49
|
+
const details = [];
|
|
50
|
+
if (failure.status !== undefined)
|
|
51
|
+
details.push(`status ${failure.status}`);
|
|
52
|
+
if (failure.errorMessage !== undefined)
|
|
53
|
+
details.push(`errorMessage ${describeValue(failure.errorMessage)}`);
|
|
54
|
+
if (failure.zodError !== undefined)
|
|
55
|
+
details.push(`zodError ${describeValue(failure.zodError.message)}`);
|
|
56
|
+
// The error's own message, and its cause when it has one: an in-process
|
|
57
|
+
// transport reports a handler that threw as a failure whose cause is
|
|
58
|
+
// what actually threw, and that is the line a test author needs.
|
|
59
|
+
if (failure.error instanceof Error) {
|
|
60
|
+
const cause = failure.error.cause instanceof Error ? ` (cause: ${failure.error.cause.message})` : "";
|
|
61
|
+
details.push(`error "${failure.error.message}"${cause}`);
|
|
62
|
+
}
|
|
63
|
+
return `a failure with reason "${failure.reason}"${details.length ? `, ${details.join(", ")}` : ""}`;
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Asserts that a call succeeded, and narrows the outcome to its success arm,
|
|
67
|
+
* so `outcome.payload` reads directly on the next line.
|
|
68
|
+
*
|
|
69
|
+
* ```typescript
|
|
70
|
+
* const outcome = await visitor.apiOutcome("order.create", { sku });
|
|
71
|
+
* assertApiSuccess(outcome);
|
|
72
|
+
* expect(outcome.payload?.orderId).toBeDefined();
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
export function assertApiSuccess(outcome) {
|
|
76
|
+
if (!outcome.ok)
|
|
77
|
+
throw new Error(`Expected the call to succeed, but it was ${describeOutcome(outcome)}.`, { cause: errorOf(outcome) });
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Asserts that a call failed, with the given reason when one is named, and
|
|
81
|
+
* narrows the outcome to the arms that reason can be, so what it carries
|
|
82
|
+
* (`zodError` after "validation", `response` after an envelope reason,
|
|
83
|
+
* `error` after the rest) reads directly on the next line.
|
|
84
|
+
*
|
|
85
|
+
* ```typescript
|
|
86
|
+
* assertApiFailure(await member.apiOutcome("org.delete", { id }), "notAuthorized");
|
|
87
|
+
* assertApiFailure(await guest.apiOutcome("signup", form), "errorMessage", { code: LAMBDER_REFUSAL_CODES.rateLimited, status: 429 });
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
export function assertApiFailure(outcome, reason, expected = {}) {
|
|
91
|
+
const wanted = [
|
|
92
|
+
reason !== undefined ? `reason "${String(reason)}"` : null,
|
|
93
|
+
expected.code !== undefined ? `code "${expected.code}"` : null,
|
|
94
|
+
expected.status !== undefined ? `status ${expected.status}` : null,
|
|
95
|
+
].filter((part) => part !== null).join(", ");
|
|
96
|
+
const refuse = () => {
|
|
97
|
+
throw new Error(`Expected the call to fail${wanted ? ` with ${wanted}` : ""}, but it was ${describeOutcome(outcome)}.`, { cause: errorOf(outcome) });
|
|
98
|
+
};
|
|
99
|
+
if (outcome.ok)
|
|
100
|
+
return refuse();
|
|
101
|
+
if (reason !== undefined && outcome.reason !== reason)
|
|
102
|
+
return refuse();
|
|
103
|
+
if (expected.code !== undefined) {
|
|
104
|
+
// Only the structured errorMessage carries a code; a plain string has none to match.
|
|
105
|
+
const errorMessage = outcome.errorMessage;
|
|
106
|
+
const code = errorMessage && typeof errorMessage === "object" ? errorMessage.code : undefined;
|
|
107
|
+
if (code !== expected.code)
|
|
108
|
+
return refuse();
|
|
109
|
+
}
|
|
110
|
+
if (expected.status !== undefined && outcome.status !== expected.status)
|
|
111
|
+
return refuse();
|
|
112
|
+
}
|
|
@@ -2,27 +2,27 @@
|
|
|
2
2
|
* Request payload compression: the wire format both sides speak.
|
|
3
3
|
*
|
|
4
4
|
* When a LambderCaller call's payload clears the configured size, the caller
|
|
5
|
-
* sends the payload's JSON as `payloadGz` (gzip bytes, base64)
|
|
5
|
+
* sends the payload's JSON as `payloadGz` (gzip bytes, base64) plus
|
|
6
6
|
* `payloadBytes` (its UTF-8 byte length) in place of `payload`, and the
|
|
7
7
|
* server restores it before anything reads the payload. A Node caller
|
|
8
8
|
* (LambderInvokeCaller) sends `payloadBr` instead, Brotli under the same
|
|
9
|
-
* rules; the server accepts either.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* rules; the server accepts either. The rest of the envelope (apiName,
|
|
10
|
+
* version, token, siteHost, guardInputs, idempotencyKey) stays plain text,
|
|
11
|
+
* so routing, logging and request mocking are unaffected.
|
|
12
12
|
*
|
|
13
|
-
* Base64 inside the JSON envelope
|
|
14
|
-
* Content-Encoding: API Gateway hands a binary
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
13
|
+
* Base64 inside the JSON envelope rather than a binary body with
|
|
14
|
+
* Content-Encoding: API Gateway hands a binary body to Lambda base64-encoded
|
|
15
|
+
* anyway, so binary saves nothing against Lambda's ~6MB invoke cap while
|
|
16
|
+
* adding content-type negotiation that gateways, CDNs and mock servers each
|
|
17
|
+
* treat differently. Base64's 4/3 overhead applies to bytes that already
|
|
18
|
+
* shrank several times over.
|
|
19
19
|
*
|
|
20
20
|
* gzip rather than Brotli because the browser's CompressionStream offers
|
|
21
|
-
* gzip and deflate
|
|
21
|
+
* only gzip and deflate; responses, compressed by Node, prefer Brotli.
|
|
22
22
|
*
|
|
23
|
-
* `payloadBytes` is not bookkeeping: it bounds the server's decompression
|
|
24
|
-
* and the restored length must match it exactly
|
|
25
|
-
* LambderCompressionCodec gives stored records, so a malicious or truncated
|
|
23
|
+
* `payloadBytes` is not bookkeeping: it bounds the server's decompression,
|
|
24
|
+
* and the restored length must match it exactly (the guarantee
|
|
25
|
+
* LambderCompressionCodec gives stored records), so a malicious or truncated
|
|
26
26
|
* body fails instead of expanding without limit.
|
|
27
27
|
*/
|
|
28
28
|
import type { LambderCompressionOption, LambderCompressionSettings } from "./LambderCompressionOption.js";
|
|
@@ -76,9 +76,8 @@ export declare const DEFAULT_MAX_RESTORED_PAYLOAD_BYTES = 20000000;
|
|
|
76
76
|
* `compressRequest: true` means "whatever the size", which is a threshold of
|
|
77
77
|
* zero rather than a separate path.
|
|
78
78
|
*
|
|
79
|
-
* Both callers decide this, and the
|
|
80
|
-
*
|
|
81
|
-
* beside the compressors it feeds.
|
|
79
|
+
* Both callers decide this, and the override is easy to get backwards, so it
|
|
80
|
+
* is written once, beside the compressors it feeds.
|
|
82
81
|
*/
|
|
83
82
|
export declare const resolveRequestCompressionMinBytes: (compressRequest: boolean | undefined, settings: {
|
|
84
83
|
minBytes: number;
|
|
@@ -87,11 +86,10 @@ export declare const resolveRequestCompressionMinBytes: (compressRequest: boolea
|
|
|
87
86
|
export declare const isRequestCompressionAvailable: () => boolean;
|
|
88
87
|
/**
|
|
89
88
|
* Gzip one payload's JSON for sending, or null when the plain JSON should go
|
|
90
|
-
* instead (see compressPayloadWith for the two rules). The second
|
|
89
|
+
* instead (see compressPayloadWith for the two rules). The second rule
|
|
91
90
|
* matters for the payloads most likely to be large: a base64 image gzips to
|
|
92
91
|
* nearly its own size, and base64 then inflates the result past the
|
|
93
|
-
* original
|
|
94
|
-
* bigger, so the compressed form is only ever sent when it is smaller.
|
|
92
|
+
* original, so sending it would cost CPU on both ends for a bigger request.
|
|
95
93
|
*/
|
|
96
94
|
export declare const compressPayloadGzip: (json: string, minBytes: number) => Promise<LambderCompressedGzipPayload | null>;
|
|
97
95
|
/** Request Brotli when `requestCompression: true` on LambderInvokeCaller: the HTTP request threshold, at the quality every other Lambder site uses. */
|
|
@@ -51,9 +51,8 @@ const compressPayloadWith = async (json, minBytes, field, compress) => {
|
|
|
51
51
|
* `compressRequest: true` means "whatever the size", which is a threshold of
|
|
52
52
|
* zero rather than a separate path.
|
|
53
53
|
*
|
|
54
|
-
* Both callers decide this, and the
|
|
55
|
-
*
|
|
56
|
-
* beside the compressors it feeds.
|
|
54
|
+
* Both callers decide this, and the override is easy to get backwards, so it
|
|
55
|
+
* is written once, beside the compressors it feeds.
|
|
57
56
|
*/
|
|
58
57
|
export const resolveRequestCompressionMinBytes = (compressRequest, settings) => compressRequest === true ? 0
|
|
59
58
|
: compressRequest === false ? null
|
|
@@ -62,11 +61,10 @@ export const resolveRequestCompressionMinBytes = (compressRequest, settings) =>
|
|
|
62
61
|
export const isRequestCompressionAvailable = () => typeof CompressionStream !== "undefined" && typeof btoa !== "undefined";
|
|
63
62
|
/**
|
|
64
63
|
* Gzip one payload's JSON for sending, or null when the plain JSON should go
|
|
65
|
-
* instead (see compressPayloadWith for the two rules). The second
|
|
64
|
+
* instead (see compressPayloadWith for the two rules). The second rule
|
|
66
65
|
* matters for the payloads most likely to be large: a base64 image gzips to
|
|
67
66
|
* nearly its own size, and base64 then inflates the result past the
|
|
68
|
-
* original
|
|
69
|
-
* bigger, so the compressed form is only ever sent when it is smaller.
|
|
67
|
+
* original, so sending it would cost CPU on both ends for a bigger request.
|
|
70
68
|
*/
|
|
71
69
|
export const compressPayloadGzip = (json, minBytes) => compressPayloadWith(json, minBytes, COMPRESSED_PAYLOAD_GZ_FIELD, async (bytes) => {
|
|
72
70
|
const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream("gzip"));
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a cache's fill runs the loader and keeps its value: `store` writes the
|
|
3
|
+
* value where the cache keeps it and answers it as stored. Handed to the
|
|
4
|
+
* cache at the point it fills, so the store can carry what only that point
|
|
5
|
+
* knows (LambderDdbCache's fill lease).
|
|
6
|
+
*/
|
|
7
|
+
export type LambderCacheLoad<T> = (store: (value: T) => Promise<T>) => Promise<T>;
|
|
8
|
+
/**
|
|
9
|
+
* getOrSet's contract, shared by every cache here: concurrent calls for one
|
|
10
|
+
* key in one process share a single load, each handed a parse of its own as
|
|
11
|
+
* every read is, a loader's failure is the caller's, and the cache's own
|
|
12
|
+
* failure (a read, a lease, a write) never is.
|
|
13
|
+
* A cache failure after the loader answered hands that value back uncached,
|
|
14
|
+
* and one before the loader ran calls the loader directly, so the loader runs
|
|
15
|
+
* at most once per call whatever breaks. A loader's undefined has nothing to
|
|
16
|
+
* cache and comes back as it is, so the next call loads again (a loader
|
|
17
|
+
* answers null to cache "not found"); anything else is answered as the JSON
|
|
18
|
+
* it was stored as, the filling call included, so it has the shape every
|
|
19
|
+
* later hit has.
|
|
20
|
+
*
|
|
21
|
+
* A write of the key (set, delete, deletePartition) while a fill is loading
|
|
22
|
+
* wins over the fill: the loader may have read its source before whatever
|
|
23
|
+
* that write records, so storing its value afterwards would put back what the
|
|
24
|
+
* write replaced. The write takes the fill out of the in-flight map, a fill
|
|
25
|
+
* stores only while the map still holds it, and a call arriving after the
|
|
26
|
+
* write starts a load of its own instead of joining the old one. The old
|
|
27
|
+
* fill's callers get its value uncached.
|
|
28
|
+
*
|
|
29
|
+
* LambderDdbCache and LambderMemoryCache each supply only their read-then-fill
|
|
30
|
+
* and hold one filler, so the two cannot drift on any of the above.
|
|
31
|
+
*/
|
|
32
|
+
export declare class LambderCacheFiller {
|
|
33
|
+
private readonly inFlight;
|
|
34
|
+
/** Opens the log line of a fail-open, naming the cache: "DynamoDB cache failed open in geo". */
|
|
35
|
+
private readonly failOpenLabel;
|
|
36
|
+
constructor(failOpenLabel: string);
|
|
37
|
+
/**
|
|
38
|
+
* The value for `memoryKey`: `readOrFill` is the cache's own read, and
|
|
39
|
+
* its fill when the read finds nothing, handed `load` to call (at most
|
|
40
|
+
* once) at that point with the store that keeps the loader's value.
|
|
41
|
+
*/
|
|
42
|
+
getOrSet<T>(memoryKey: string, loader: () => Promise<T>, readOrFill: (load: LambderCacheLoad<T>) => Promise<T>): Promise<T>;
|
|
43
|
+
/** Called by a write of `memoryKey` before it writes: the fill in flight for it stores nothing (see the class comment). */
|
|
44
|
+
supersedeFill(memoryKey: string): void;
|
|
45
|
+
/** supersedeFill for every key starting with `memoryKeyPrefix`: one partition's keys, or all of them for "". */
|
|
46
|
+
supersedeFillsWithPrefix(memoryKeyPrefix: string): void;
|
|
47
|
+
private failOpen;
|
|
48
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { asStoredJson } from "./LambderCacheValues.js";
|
|
2
|
+
/**
|
|
3
|
+
* getOrSet's contract, shared by every cache here: concurrent calls for one
|
|
4
|
+
* key in one process share a single load, each handed a parse of its own as
|
|
5
|
+
* every read is, a loader's failure is the caller's, and the cache's own
|
|
6
|
+
* failure (a read, a lease, a write) never is.
|
|
7
|
+
* A cache failure after the loader answered hands that value back uncached,
|
|
8
|
+
* and one before the loader ran calls the loader directly, so the loader runs
|
|
9
|
+
* at most once per call whatever breaks. A loader's undefined has nothing to
|
|
10
|
+
* cache and comes back as it is, so the next call loads again (a loader
|
|
11
|
+
* answers null to cache "not found"); anything else is answered as the JSON
|
|
12
|
+
* it was stored as, the filling call included, so it has the shape every
|
|
13
|
+
* later hit has.
|
|
14
|
+
*
|
|
15
|
+
* A write of the key (set, delete, deletePartition) while a fill is loading
|
|
16
|
+
* wins over the fill: the loader may have read its source before whatever
|
|
17
|
+
* that write records, so storing its value afterwards would put back what the
|
|
18
|
+
* write replaced. The write takes the fill out of the in-flight map, a fill
|
|
19
|
+
* stores only while the map still holds it, and a call arriving after the
|
|
20
|
+
* write starts a load of its own instead of joining the old one. The old
|
|
21
|
+
* fill's callers get its value uncached.
|
|
22
|
+
*
|
|
23
|
+
* LambderDdbCache and LambderMemoryCache each supply only their read-then-fill
|
|
24
|
+
* and hold one filler, so the two cannot drift on any of the above.
|
|
25
|
+
*/
|
|
26
|
+
export class LambderCacheFiller {
|
|
27
|
+
inFlight = new Map();
|
|
28
|
+
/** Opens the log line of a fail-open, naming the cache: "DynamoDB cache failed open in geo". */
|
|
29
|
+
failOpenLabel;
|
|
30
|
+
constructor(failOpenLabel) {
|
|
31
|
+
this.failOpenLabel = failOpenLabel;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The value for `memoryKey`: `readOrFill` is the cache's own read, and
|
|
35
|
+
* its fill when the read finds nothing, handed `load` to call (at most
|
|
36
|
+
* once) at that point with the store that keeps the loader's value.
|
|
37
|
+
*/
|
|
38
|
+
getOrSet(memoryKey, loader, readOrFill) {
|
|
39
|
+
const current = this.inFlight.get(memoryKey);
|
|
40
|
+
if (current) {
|
|
41
|
+
// A parse of its own, as a read hands back: the first call's
|
|
42
|
+
// object is that caller's to change, and a change must not show
|
|
43
|
+
// in anyone else's answer.
|
|
44
|
+
current.joiners += 1;
|
|
45
|
+
return current.answer.then((value) => current.answerJson === undefined ? value : JSON.parse(current.answerJson));
|
|
46
|
+
}
|
|
47
|
+
const leave = () => {
|
|
48
|
+
// A write may have handed the key to a newer fill meanwhile.
|
|
49
|
+
if (this.inFlight.get(memoryKey) === shared)
|
|
50
|
+
this.inFlight.delete(memoryKey);
|
|
51
|
+
};
|
|
52
|
+
const shared = {
|
|
53
|
+
joiners: 0,
|
|
54
|
+
answer: this.failOpen(loader, readOrFill, () => this.inFlight.get(memoryKey) !== shared).then((value) => {
|
|
55
|
+
// Out of the map in the same step the text is taken, and
|
|
56
|
+
// before any caller holds the value: no call joins after
|
|
57
|
+
// this, and none has changed the value yet.
|
|
58
|
+
leave();
|
|
59
|
+
if (shared.joiners > 0) {
|
|
60
|
+
// A value JSON cannot hold (a bigint the loader
|
|
61
|
+
// answered on a fail-open) goes to every caller as it is.
|
|
62
|
+
try {
|
|
63
|
+
shared.answerJson = JSON.stringify(value);
|
|
64
|
+
}
|
|
65
|
+
catch { }
|
|
66
|
+
}
|
|
67
|
+
return value;
|
|
68
|
+
}, (error) => {
|
|
69
|
+
leave();
|
|
70
|
+
throw error;
|
|
71
|
+
}),
|
|
72
|
+
};
|
|
73
|
+
this.inFlight.set(memoryKey, shared);
|
|
74
|
+
return shared.answer;
|
|
75
|
+
}
|
|
76
|
+
/** Called by a write of `memoryKey` before it writes: the fill in flight for it stores nothing (see the class comment). */
|
|
77
|
+
supersedeFill(memoryKey) {
|
|
78
|
+
this.inFlight.delete(memoryKey);
|
|
79
|
+
}
|
|
80
|
+
/** supersedeFill for every key starting with `memoryKeyPrefix`: one partition's keys, or all of them for "". */
|
|
81
|
+
supersedeFillsWithPrefix(memoryKeyPrefix) {
|
|
82
|
+
for (const memoryKey of [...this.inFlight.keys()]) {
|
|
83
|
+
if (memoryKey.startsWith(memoryKeyPrefix))
|
|
84
|
+
this.inFlight.delete(memoryKey);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async failOpen(loader, readOrFill, superseded) {
|
|
88
|
+
let loaderStarted = false;
|
|
89
|
+
let loaderCompleted = false;
|
|
90
|
+
let loaderValue;
|
|
91
|
+
const trackedLoader = async () => {
|
|
92
|
+
loaderStarted = true;
|
|
93
|
+
loaderValue = await loader();
|
|
94
|
+
loaderCompleted = true;
|
|
95
|
+
return loaderValue;
|
|
96
|
+
};
|
|
97
|
+
const load = async (store) => {
|
|
98
|
+
const value = await trackedLoader();
|
|
99
|
+
if (value === undefined)
|
|
100
|
+
return value;
|
|
101
|
+
if (superseded())
|
|
102
|
+
return asStoredJson(value);
|
|
103
|
+
return await store(value);
|
|
104
|
+
};
|
|
105
|
+
try {
|
|
106
|
+
return await readOrFill(load);
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
if (loaderStarted && !loaderCompleted)
|
|
110
|
+
throw error;
|
|
111
|
+
// The key stays out of the line: it is the app's data (an email
|
|
112
|
+
// address, a user id), and the other engines keep theirs out too.
|
|
113
|
+
console.error(`${this.failOpenLabel}; the loader's value is handed back uncached.`, error);
|
|
114
|
+
if (loaderCompleted)
|
|
115
|
+
return asStoredJson(loaderValue);
|
|
116
|
+
return asStoredJson(await trackedLoader());
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|