lambder 7.3.1 → 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 +933 -3
- package/README.md +41 -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 +68 -62
- package/dist/api/LambderApiIdempotency.js +214 -151
- package/dist/api/LambderApiOutputValidationError.d.ts +32 -0
- package/dist/api/LambderApiOutputValidationError.js +50 -0
- package/dist/api/LambderApiPipeline.d.ts +47 -38
- package/dist/api/LambderApiPipeline.js +122 -63
- package/dist/api/LambderApiRateLimits.d.ts +201 -54
- package/dist/api/LambderApiRateLimits.js +185 -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 +140 -75
- package/dist/core/Lambder.js +347 -227
- 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 +21 -7
- package/dist/core/LambderFiles.js +62 -34
- 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 +29 -9
- package/dist/invoke/LambderLambdaEvent.js +40 -22
- package/dist/invoke/lambderHandlerTransport.d.ts +9 -10
- package/dist/invoke/lambderHandlerTransport.js +15 -18
- 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 +1 -1
- package/dist/mock.js +2 -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 +124 -46
- package/dist/session/LambderSessionManager.js +262 -137
- 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/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 +6 -7
- package/dist/shared/wire/LambderOutcomeAssertions.js +6 -7
- 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 +21 -23
- package/dist/testing/LambderTestApp.js +22 -24
- package/dist/testing/LambderTestVisitor.d.ts +10 -12
- package/dist/testing/LambderTestVisitor.js +15 -15
- package/dist/testing.d.ts +1 -0
- package/dist/testing.js +1 -0
- package/package.json +12 -3
- package/dist/api/LambderApiPolicyEngine.d.ts +0 -47
- package/dist/api/LambderApiPolicyEngine.js +0 -85
- package/dist/shared/util/LambderKeyFields.d.ts +0 -32
- package/dist/shared/util/LambderKeyFields.js +0 -34
|
@@ -10,14 +10,12 @@ import { LambderExpiringMap, LambderExpiringMapFullError } from "../shared/util/
|
|
|
10
10
|
* path can be exercised; unbounded by default. `now` is injectable so a test
|
|
11
11
|
* can expire a claim without waiting.
|
|
12
12
|
*
|
|
13
|
-
* `maxEntries`
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* exists to prevent; a claim that cannot be made room for is reported as
|
|
20
|
-
* "pending" instead, so the duplicate is refused rather than run.
|
|
13
|
+
* `maxEntries` (100,000 by default) is the one way this store differs from a
|
|
14
|
+
* table: past the ceiling, SETTLED records are dropped, soonest expiry first,
|
|
15
|
+
* and a retry whose record was dropped executes again instead of replaying.
|
|
16
|
+
* Pending claims are never dropped, since losing one lets two concurrent
|
|
17
|
+
* retries both execute; a claim that finds no room is reported as "pending"
|
|
18
|
+
* instead, so the duplicate is refused rather than run.
|
|
21
19
|
*/
|
|
22
20
|
export class LambderMemoryIdempotencyStore {
|
|
23
21
|
records;
|
|
@@ -34,7 +32,7 @@ export class LambderMemoryIdempotencyStore {
|
|
|
34
32
|
nowSeconds() { return Math.floor(this.now() / 1000); }
|
|
35
33
|
/** A settled record as the engine reads it: a copy, so a caller writing onto what it got back cannot rewrite the record. */
|
|
36
34
|
static answerOf(record) {
|
|
37
|
-
return { statusCode: record.statusCode, headers: structuredClone(record.headers), body: record.body };
|
|
35
|
+
return { statusCode: record.statusCode, headers: structuredClone(record.headers), body: record.body, fingerprint: record.fingerprint };
|
|
38
36
|
}
|
|
39
37
|
async peek(scopeKey) {
|
|
40
38
|
const record = this.records.get(scopeKey);
|
|
@@ -42,43 +40,45 @@ export class LambderMemoryIdempotencyStore {
|
|
|
42
40
|
return null;
|
|
43
41
|
return LambderMemoryIdempotencyStore.answerOf(record);
|
|
44
42
|
}
|
|
45
|
-
async begin(scopeKey, { pendingTtlSeconds }) {
|
|
43
|
+
async begin(scopeKey, { pendingTtlSeconds, fingerprint }) {
|
|
46
44
|
const existing = this.records.get(scopeKey);
|
|
47
45
|
if (existing) {
|
|
48
46
|
if (existing.state === "done")
|
|
49
47
|
return { state: "done", ...LambderMemoryIdempotencyStore.answerOf(existing) };
|
|
50
|
-
return { state: "pending" };
|
|
48
|
+
return { state: "pending", fingerprint: existing.fingerprint };
|
|
51
49
|
}
|
|
52
50
|
this.ownerCounter += 1;
|
|
53
51
|
const ownerToken = `owner-${this.ownerCounter}`;
|
|
54
52
|
try {
|
|
55
|
-
// Not evictable: the claim is
|
|
56
|
-
// concurrent retries and two executions, and
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
this.records.set(scopeKey, { state: "pending", ownerToken }, this.nowSeconds() + pendingTtlSeconds, { evictable: false });
|
|
53
|
+
// Not evictable: the claim is all that stands between two
|
|
54
|
+
// concurrent retries and two executions, and the soonest-expiry
|
|
55
|
+
// rule would take it first (a claim lives minutes, a record a day).
|
|
56
|
+
this.records.set(scopeKey, { state: "pending", ownerToken, fingerprint }, this.nowSeconds() + pendingTtlSeconds, { evictable: false });
|
|
60
57
|
}
|
|
61
58
|
catch (error) {
|
|
62
59
|
if (!(error instanceof LambderExpiringMapFullError))
|
|
63
60
|
throw error;
|
|
64
|
-
// Every record held is a live claim, so
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
61
|
+
// Every record held is a live claim, so nothing is safe to take.
|
|
62
|
+
// "pending" means "the claim is not yours": the caller refuses the
|
|
63
|
+
// duplicate, which cannot execute anything twice, and a retry
|
|
64
|
+
// after the flood drains claims normally. It carries the caller's
|
|
65
|
+
// own fingerprint, so the engine answers the in-flight 409 and the
|
|
66
|
+
// client retries under the same key; any other fingerprint would
|
|
67
|
+
// be the key-reused 409, which moves a client's key scope on as
|
|
68
|
+
// though the operation were settled.
|
|
69
69
|
if (!this.claimCeilingReported) {
|
|
70
70
|
this.claimCeilingReported = true;
|
|
71
71
|
console.error("LambderMemoryIdempotencyStore: every record held is a live claim, so new requests are refused as duplicates until one settles. Raise maxEntries or move to LambderDdbIdempotencyStore.");
|
|
72
72
|
}
|
|
73
|
-
return { state: "pending" };
|
|
73
|
+
return { state: "pending", fingerprint };
|
|
74
74
|
}
|
|
75
75
|
this.claimCeilingReported = false;
|
|
76
76
|
return { state: "new", ownerToken };
|
|
77
77
|
}
|
|
78
|
-
async complete(scopeKey, ownerToken, { statusCode, headers, body, ttlSeconds }) {
|
|
79
|
-
// Size first, ownership second,
|
|
80
|
-
//
|
|
81
|
-
// decide before going to the table
|
|
78
|
+
async complete(scopeKey, ownerToken, { statusCode, headers, body, fingerprint, ttlSeconds }) {
|
|
79
|
+
// Size first, ownership second, as LambderDdbIdempotencyStore must:
|
|
80
|
+
// it settles with one conditional write, so whether the body fits is
|
|
81
|
+
// all it can decide before going to the table.
|
|
82
82
|
if (new TextEncoder().encode(body).length > this.maxBodyBytes)
|
|
83
83
|
return "too-large";
|
|
84
84
|
const existing = this.records.get(scopeKey);
|
|
@@ -87,18 +87,19 @@ export class LambderMemoryIdempotencyStore {
|
|
|
87
87
|
// Settled records ARE evictable: the claim's job is done, and a record
|
|
88
88
|
// the ceiling drops costs a retry its replay, not its exclusivity.
|
|
89
89
|
// The key is already held, so this write cannot cross the ceiling.
|
|
90
|
-
this.records.set(scopeKey, { state: "done", ownerToken, statusCode, headers: structuredClone(headers), body }, this.nowSeconds() + ttlSeconds);
|
|
90
|
+
this.records.set(scopeKey, { state: "done", ownerToken, statusCode, headers: structuredClone(headers), body, fingerprint }, this.nowSeconds() + ttlSeconds);
|
|
91
91
|
return "stored";
|
|
92
92
|
}
|
|
93
|
+
/** Releases a pending claim for its owner; a settled record stays, as LambderIdempotencyStore requires. */
|
|
93
94
|
async abandon(scopeKey, ownerToken) {
|
|
94
95
|
const existing = this.records.get(scopeKey);
|
|
95
|
-
if (existing && existing.ownerToken === ownerToken)
|
|
96
|
+
if (existing?.state === "pending" && existing.ownerToken === ownerToken)
|
|
96
97
|
this.records.delete(scopeKey);
|
|
97
98
|
}
|
|
98
99
|
/**
|
|
99
100
|
* The record under a scope, for assertions; null when absent or expired.
|
|
100
|
-
* A copy, like every
|
|
101
|
-
*
|
|
101
|
+
* A copy, like every read here, so an assertion cannot edit the stored
|
|
102
|
+
* record.
|
|
102
103
|
*/
|
|
103
104
|
recordOf(scopeKey) {
|
|
104
105
|
const record = this.records.get(scopeKey);
|
|
@@ -9,15 +9,13 @@ import { type LambderRateLimiter, type LambderRateLimitPolicy, type LambderRateL
|
|
|
9
9
|
* `now` is injectable so a test can move time forward and watch a window
|
|
10
10
|
* reset without waiting for it.
|
|
11
11
|
*
|
|
12
|
-
* `maxEntries`
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* last to go. A deployment where that matters wants LambderDdbRateLimiter,
|
|
20
|
-
* whose counters are not held in the process at all.
|
|
12
|
+
* `maxEntries` (100,000 counters by default) is the one way this limiter
|
|
13
|
+
* differs from the table: past the ceiling, the counters closest to their
|
|
14
|
+
* window's end are dropped, and a key whose counter was dropped starts that
|
|
15
|
+
* window again from zero. Reaching it takes one distinct key per counter (a
|
|
16
|
+
* per-IP limit under a flood from many IPs), and the long-window counters,
|
|
17
|
+
* with the most life left, go last. Where that matters, use
|
|
18
|
+
* LambderDdbRateLimiter, whose counters live outside the process.
|
|
21
19
|
*/
|
|
22
20
|
export declare class LambderMemoryRateLimiter implements LambderRateLimiter {
|
|
23
21
|
private readonly counters;
|
|
@@ -26,6 +24,7 @@ export declare class LambderMemoryRateLimiter implements LambderRateLimiter {
|
|
|
26
24
|
now?: () => number;
|
|
27
25
|
maxEntries?: number;
|
|
28
26
|
});
|
|
27
|
+
clockMilliseconds(): number;
|
|
29
28
|
isRateLimited(trackerKey: string, policy: LambderRateLimitPolicy): Promise<LambderRateLimitResult>;
|
|
30
29
|
/** The attempts counted so far for a key in the current window of `window`; for assertions. */
|
|
31
30
|
countOf(trackerKey: string, window: LambderRateLimitWindow): number;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { RATE_LIMIT_WINDOWS, } from "../shared/contracts/LambderRateLimiter.js";
|
|
2
2
|
import { LambderExpiringMap } from "../shared/util/LambderExpiringMap.js";
|
|
3
|
-
import { joinKeyFields } from "../shared/util/
|
|
3
|
+
import { joinKeyFields } from "../shared/util/joinKeyFields.js";
|
|
4
4
|
/**
|
|
5
5
|
* Fixed-window rate limiter held in memory: the same windows, the same
|
|
6
6
|
* attempts-count semantics and the same evaluation order as
|
|
@@ -11,15 +11,13 @@ import { joinKeyFields } from "../shared/util/LambderKeyFields.js";
|
|
|
11
11
|
* `now` is injectable so a test can move time forward and watch a window
|
|
12
12
|
* reset without waiting for it.
|
|
13
13
|
*
|
|
14
|
-
* `maxEntries`
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* last to go. A deployment where that matters wants LambderDdbRateLimiter,
|
|
22
|
-
* whose counters are not held in the process at all.
|
|
14
|
+
* `maxEntries` (100,000 counters by default) is the one way this limiter
|
|
15
|
+
* differs from the table: past the ceiling, the counters closest to their
|
|
16
|
+
* window's end are dropped, and a key whose counter was dropped starts that
|
|
17
|
+
* window again from zero. Reaching it takes one distinct key per counter (a
|
|
18
|
+
* per-IP limit under a flood from many IPs), and the long-window counters,
|
|
19
|
+
* with the most life left, go last. Where that matters, use
|
|
20
|
+
* LambderDdbRateLimiter, whose counters live outside the process.
|
|
23
21
|
*/
|
|
24
22
|
export class LambderMemoryRateLimiter {
|
|
25
23
|
counters;
|
|
@@ -28,6 +26,9 @@ export class LambderMemoryRateLimiter {
|
|
|
28
26
|
this.now = options.now ?? (() => Date.now());
|
|
29
27
|
this.counters = new LambderExpiringMap({ now: this.now, maxEntries: options.maxEntries });
|
|
30
28
|
}
|
|
29
|
+
clockMilliseconds() {
|
|
30
|
+
return this.now();
|
|
31
|
+
}
|
|
31
32
|
async isRateLimited(trackerKey, policy) {
|
|
32
33
|
const nowSeconds = Math.floor(this.now() / 1000);
|
|
33
34
|
for (const { key, seconds } of RATE_LIMIT_WINDOWS) {
|
|
@@ -35,9 +36,9 @@ export class LambderMemoryRateLimiter {
|
|
|
35
36
|
if (!limit)
|
|
36
37
|
continue;
|
|
37
38
|
const windowStart = Math.floor(nowSeconds / seconds) * seconds;
|
|
38
|
-
// The table
|
|
39
|
-
// attributes; one string
|
|
40
|
-
//
|
|
39
|
+
// The table keeps the tracker key and the window in separate
|
|
40
|
+
// attributes; one string has to keep them as distinct, hence the
|
|
41
|
+
// escaping join.
|
|
41
42
|
const counterKey = joinKeyFields(trackerKey, key, String(windowStart));
|
|
42
43
|
const counter = this.counters.get(counterKey) ?? { count: 0 };
|
|
43
44
|
// The DynamoDB limiter's conditional ADD: allowed while the count
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { LambderSessionRecord, LambderSessionStore } from "../shared/contracts/LambderSessionStore.js";
|
|
1
|
+
import type { LambderSessionChanges, LambderSessionRecord, LambderSessionStore, LambderSessionUpdateResult } from "../shared/contracts/LambderSessionStore.js";
|
|
2
2
|
/**
|
|
3
3
|
* Session records held in memory: a Map in place of the DynamoDB table. For
|
|
4
4
|
* tests and for the mock runtime, where the session model (tokens, expiry,
|
|
@@ -7,9 +7,11 @@ import type { LambderSessionRecord, LambderSessionStore } from "../shared/contra
|
|
|
7
7
|
*
|
|
8
8
|
* Records are copied on the way in and out through JSON, the way a real store
|
|
9
9
|
* serializes them, so a caller mutating a fetched record changes nothing until
|
|
10
|
-
* it is
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* it is written back, and data DynamoDB would reject is rejected here too.
|
|
11
|
+
* Creates and updates carry the table's conditions: a create refuses an
|
|
12
|
+
* existing record, and an update never brings back a deleted one. A record
|
|
13
|
+
* also leaves at its own expiresAt, as the table's TTL removes it, so a
|
|
14
|
+
* long-lived process does not hold every session it ever issued.
|
|
13
15
|
*/
|
|
14
16
|
export declare class LambderMemorySessionStore<SessionData = unknown> implements LambderSessionStore<SessionData> {
|
|
15
17
|
/** Nothing here outlives the process. */
|
|
@@ -28,17 +30,18 @@ export declare class LambderMemorySessionStore<SessionData = unknown> implements
|
|
|
28
30
|
private static keyOf;
|
|
29
31
|
/**
|
|
30
32
|
* A record the way a real store hands it back: through JSON, so an
|
|
31
|
-
* undefined field is gone and a cyclic value throws here exactly as
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* data the production store would reject.
|
|
33
|
+
* undefined field is gone and a cyclic value throws here exactly as on the
|
|
34
|
+
* way into DynamoDB. structuredClone would do neither, and a test could
|
|
35
|
+
* then pass over data the production store rejects.
|
|
35
36
|
*/
|
|
36
37
|
private static storedCopy;
|
|
37
38
|
get(sessionKeyHash: string, secretHash: string): Promise<LambderSessionRecord<SessionData> | null>;
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
create(record: LambderSessionRecord<SessionData>): Promise<void>;
|
|
40
|
+
update(sessionKeyHash: string, secretHash: string, changes: LambderSessionChanges<SessionData>, condition?: {
|
|
41
|
+
dataVersion: number;
|
|
42
|
+
}): Promise<LambderSessionUpdateResult>;
|
|
43
|
+
delete(sessionKeyHash: string, secretHash: string): Promise<LambderSessionRecord<SessionData> | null>;
|
|
40
44
|
listSecretHashes(sessionKeyHash: string): Promise<string[]>;
|
|
41
|
-
markDataExpired(sessionKeyHash: string, secretHash: string, at: number): Promise<void>;
|
|
42
45
|
/** Every live record held, for assertions. */
|
|
43
46
|
list(): LambderSessionRecord<SessionData>[];
|
|
44
47
|
/** Number of live records held. */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { LambderExpiringMap } from "../shared/util/LambderExpiringMap.js";
|
|
2
|
-
import { joinKeyFields } from "../shared/util/
|
|
2
|
+
import { joinKeyFields } from "../shared/util/joinKeyFields.js";
|
|
3
3
|
/**
|
|
4
4
|
* Session records held in memory: a Map in place of the DynamoDB table. For
|
|
5
5
|
* tests and for the mock runtime, where the session model (tokens, expiry,
|
|
@@ -8,9 +8,11 @@ import { joinKeyFields } from "../shared/util/LambderKeyFields.js";
|
|
|
8
8
|
*
|
|
9
9
|
* Records are copied on the way in and out through JSON, the way a real store
|
|
10
10
|
* serializes them, so a caller mutating a fetched record changes nothing until
|
|
11
|
-
* it is
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* it is written back, and data DynamoDB would reject is rejected here too.
|
|
12
|
+
* Creates and updates carry the table's conditions: a create refuses an
|
|
13
|
+
* existing record, and an update never brings back a deleted one. A record
|
|
14
|
+
* also leaves at its own expiresAt, as the table's TTL removes it, so a
|
|
15
|
+
* long-lived process does not hold every session it ever issued.
|
|
14
16
|
*/
|
|
15
17
|
export class LambderMemorySessionStore {
|
|
16
18
|
/** Nothing here outlives the process. */
|
|
@@ -26,17 +28,16 @@ export class LambderMemorySessionStore {
|
|
|
26
28
|
this.records = new LambderExpiringMap(options);
|
|
27
29
|
}
|
|
28
30
|
static keyOf(sessionKeyHash, secretHash) {
|
|
29
|
-
// Escaped rather than joined plainly:
|
|
30
|
-
// custom LambderSessionCrypto
|
|
31
|
-
//
|
|
31
|
+
// Escaped rather than joined plainly: the default crypto writes hex,
|
|
32
|
+
// but a custom LambderSessionCrypto may write anything, and two
|
|
33
|
+
// records sharing one key would silently be one session.
|
|
32
34
|
return joinKeyFields(sessionKeyHash, secretHash);
|
|
33
35
|
}
|
|
34
36
|
/**
|
|
35
37
|
* A record the way a real store hands it back: through JSON, so an
|
|
36
|
-
* undefined field is gone and a cyclic value throws here exactly as
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* data the production store would reject.
|
|
38
|
+
* undefined field is gone and a cyclic value throws here exactly as on the
|
|
39
|
+
* way into DynamoDB. structuredClone would do neither, and a test could
|
|
40
|
+
* then pass over data the production store rejects.
|
|
40
41
|
*/
|
|
41
42
|
static storedCopy(record) {
|
|
42
43
|
return JSON.parse(JSON.stringify(record));
|
|
@@ -45,22 +46,40 @@ export class LambderMemorySessionStore {
|
|
|
45
46
|
const record = this.records.get(LambderMemorySessionStore.keyOf(sessionKeyHash, secretHash));
|
|
46
47
|
return record ? LambderMemorySessionStore.storedCopy(record) : null;
|
|
47
48
|
}
|
|
48
|
-
async
|
|
49
|
-
|
|
49
|
+
async create(record) {
|
|
50
|
+
const key = LambderMemorySessionStore.keyOf(record.sessionKeyHash, record.secretHash);
|
|
51
|
+
if (this.records.get(key))
|
|
52
|
+
throw new Error("LambderMemorySessionStore: a session already exists under these hashes.");
|
|
53
|
+
this.records.set(key, LambderMemorySessionStore.storedCopy(record), record.expiresAt);
|
|
54
|
+
}
|
|
55
|
+
async update(sessionKeyHash, secretHash, changes, condition) {
|
|
56
|
+
const key = LambderMemorySessionStore.keyOf(sessionKeyHash, secretHash);
|
|
57
|
+
const record = this.records.get(key);
|
|
58
|
+
if (!record)
|
|
59
|
+
return "missing";
|
|
60
|
+
if (condition && record.dataVersion !== condition.dataVersion)
|
|
61
|
+
return "stale";
|
|
62
|
+
// A write of the data or its deadline moves the version, whatever
|
|
63
|
+
// value it writes, as the DynamoDB store's ADD does.
|
|
64
|
+
const writesData = "data" in changes || changes.dataExpiresAt !== undefined;
|
|
65
|
+
// Through the same JSON copy a create takes, so an update carries
|
|
66
|
+
// exactly what the table would hold, and the record's own expiry
|
|
67
|
+
// moves with it, the way the table's TTL attribute does.
|
|
68
|
+
const updated = LambderMemorySessionStore.storedCopy({ ...record, ...changes, dataVersion: record.dataVersion + (writesData ? 1 : 0) });
|
|
69
|
+
this.records.set(key, updated, updated.expiresAt);
|
|
70
|
+
return "updated";
|
|
50
71
|
}
|
|
51
72
|
async delete(sessionKeyHash, secretHash) {
|
|
52
|
-
|
|
73
|
+
const key = LambderMemorySessionStore.keyOf(sessionKeyHash, secretHash);
|
|
74
|
+
const record = this.records.get(key);
|
|
75
|
+
this.records.delete(key);
|
|
76
|
+
return record ? LambderMemorySessionStore.storedCopy(record) : null;
|
|
53
77
|
}
|
|
54
78
|
async listSecretHashes(sessionKeyHash) {
|
|
55
79
|
return this.records.values()
|
|
56
80
|
.filter((record) => record.sessionKeyHash === sessionKeyHash)
|
|
57
81
|
.map((record) => record.secretHash);
|
|
58
82
|
}
|
|
59
|
-
async markDataExpired(sessionKeyHash, secretHash, at) {
|
|
60
|
-
const record = this.records.get(LambderMemorySessionStore.keyOf(sessionKeyHash, secretHash));
|
|
61
|
-
if (record)
|
|
62
|
-
record.dataExpiresAt = at;
|
|
63
|
-
}
|
|
64
83
|
/** Every live record held, for assertions. */
|
|
65
84
|
list() {
|
|
66
85
|
return this.records.values().map((record) => LambderMemorySessionStore.storedCopy(record));
|
|
@@ -12,24 +12,39 @@ export type LambderS3FileSourceOptions = {
|
|
|
12
12
|
* `{ region: "auto", endpoint, credentials }`.
|
|
13
13
|
*/
|
|
14
14
|
clientConfig?: S3ClientConfig;
|
|
15
|
+
/**
|
|
16
|
+
* The S3 error names that mean "no such file", read as null so the
|
|
17
|
+
* request falls through. Default: NoSuchKey and NotFound (the key does
|
|
18
|
+
* not exist), AccessDenied (what S3 answers a reader without
|
|
19
|
+
* s3:ListBucket for a missing key, since it will not say whether the key
|
|
20
|
+
* exists), and the refusals of a key S3 will not look up at all:
|
|
21
|
+
* KeyTooLongError and InvalidURI from S3, InvalidObjectName from R2. A
|
|
22
|
+
* reader granted s3:ListBucket gets NoSuchKey for a missing key, so a
|
|
23
|
+
* list without AccessDenied makes a refused credential surface as the
|
|
24
|
+
* error it is.
|
|
25
|
+
*/
|
|
26
|
+
notFoundErrorNames?: readonly string[];
|
|
15
27
|
};
|
|
16
28
|
/**
|
|
17
29
|
* Files from an S3 bucket, or any S3-compatible store such as Cloudflare
|
|
18
30
|
* R2 (pass its endpoint in clientConfig). Needs @aws-sdk/client-s3, an
|
|
19
31
|
* optional peer dependency loaded on first read, so apps that serve from a
|
|
20
|
-
* folder never load it. A missing object reads as
|
|
21
|
-
* falls through;
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
32
|
+
* folder never load it. A missing object (see `notFoundErrorNames`) reads as
|
|
33
|
+
* null and the request falls through; any other failure throws. A request
|
|
34
|
+
* path names the key, so a key that is missing or that S3 refuses to look
|
|
35
|
+
* up is the visitor's doing, and reading it as an error would answer every
|
|
36
|
+
* such path with a 500 before an SPA's shell could be served. An object's
|
|
37
|
+
* Content-Type is used unless it is a generic octet-stream, in which case
|
|
38
|
+
* the extension decides, as for local files.
|
|
25
39
|
*/
|
|
26
40
|
export declare class LambderS3FileSource implements LambderFileSource {
|
|
27
41
|
private readonly bucket;
|
|
28
42
|
private readonly prefix;
|
|
29
43
|
private readonly clientConfig;
|
|
44
|
+
private readonly notFoundErrorNames;
|
|
30
45
|
private client;
|
|
31
46
|
private sdk;
|
|
32
|
-
constructor({ bucket, prefix, client, clientConfig }: LambderS3FileSourceOptions);
|
|
47
|
+
constructor({ bucket, prefix, client, clientConfig, notFoundErrorNames }: LambderS3FileSourceOptions);
|
|
33
48
|
private loadSdk;
|
|
34
49
|
read(relativePath: string): Promise<LambderFile | null>;
|
|
35
50
|
}
|
|
@@ -1,27 +1,32 @@
|
|
|
1
1
|
import { remoteStoreFile } from "../shared/contracts/LambderFileSource.js";
|
|
2
|
+
const DEFAULT_NOT_FOUND_ERROR_NAMES = ["NoSuchKey", "NotFound", "AccessDenied", "KeyTooLongError", "InvalidURI", "InvalidObjectName"];
|
|
2
3
|
/**
|
|
3
4
|
* Files from an S3 bucket, or any S3-compatible store such as Cloudflare
|
|
4
5
|
* R2 (pass its endpoint in clientConfig). Needs @aws-sdk/client-s3, an
|
|
5
6
|
* optional peer dependency loaded on first read, so apps that serve from a
|
|
6
|
-
* folder never load it. A missing object reads as
|
|
7
|
-
* falls through;
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* folder never load it. A missing object (see `notFoundErrorNames`) reads as
|
|
8
|
+
* null and the request falls through; any other failure throws. A request
|
|
9
|
+
* path names the key, so a key that is missing or that S3 refuses to look
|
|
10
|
+
* up is the visitor's doing, and reading it as an error would answer every
|
|
11
|
+
* such path with a 500 before an SPA's shell could be served. An object's
|
|
12
|
+
* Content-Type is used unless it is a generic octet-stream, in which case
|
|
13
|
+
* the extension decides, as for local files.
|
|
11
14
|
*/
|
|
12
15
|
export class LambderS3FileSource {
|
|
13
16
|
bucket;
|
|
14
17
|
prefix;
|
|
15
18
|
clientConfig;
|
|
19
|
+
notFoundErrorNames;
|
|
16
20
|
client;
|
|
17
21
|
sdk;
|
|
18
|
-
constructor({ bucket, prefix = "", client, clientConfig }) {
|
|
22
|
+
constructor({ bucket, prefix = "", client, clientConfig, notFoundErrorNames = DEFAULT_NOT_FOUND_ERROR_NAMES }) {
|
|
19
23
|
if (!bucket.trim())
|
|
20
24
|
throw new Error("bucket is required");
|
|
21
25
|
this.bucket = bucket;
|
|
22
26
|
this.prefix = prefix;
|
|
23
27
|
this.client = client;
|
|
24
28
|
this.clientConfig = clientConfig;
|
|
29
|
+
this.notFoundErrorNames = notFoundErrorNames;
|
|
25
30
|
}
|
|
26
31
|
loadSdk() {
|
|
27
32
|
if (!this.sdk) {
|
|
@@ -41,7 +46,7 @@ export class LambderS3FileSource {
|
|
|
41
46
|
}
|
|
42
47
|
catch (err) {
|
|
43
48
|
const name = err.name;
|
|
44
|
-
if (name === "
|
|
49
|
+
if (typeof name === "string" && this.notFoundErrorNames.includes(name))
|
|
45
50
|
return null;
|
|
46
51
|
throw err;
|
|
47
52
|
}
|
|
@@ -23,9 +23,9 @@ export type LambderTestAppOptions = {
|
|
|
23
23
|
/**
|
|
24
24
|
* The gateway shape the handler is called with: "v2" (an HTTP API, a
|
|
25
25
|
* Function URL) or "v1" (a REST API). Default: "v2". A handler answers
|
|
26
|
-
* both alike through its context, so this matters to code that reads
|
|
27
|
-
* raw `ctx.event`,
|
|
28
|
-
*
|
|
26
|
+
* both alike through its context, so this matters only to code that reads
|
|
27
|
+
* the raw `ctx.event`, or to a suite that should run on exactly what
|
|
28
|
+
* production delivers.
|
|
29
29
|
*/
|
|
30
30
|
eventFormat?: LambderHttpEventFormat;
|
|
31
31
|
/** Default: a fresh LambderMemorySessionStore. Pass your own to run the suite over another store (DynamoDB Local, say). */
|
|
@@ -46,8 +46,8 @@ export type LambderTestAppOptions = {
|
|
|
46
46
|
/**
|
|
47
47
|
* A Lambder instance as a test app takes it: any instance, read for its
|
|
48
48
|
* session data type and, through the ApiContract property rather than the
|
|
49
|
-
* class parameter, for its contract. The property
|
|
50
|
-
*
|
|
49
|
+
* class parameter, for its contract. The property lets a large app name its
|
|
50
|
+
* flattened contract interface explicitly
|
|
51
51
|
* (`lambderTestApp<SessionData, ApiContractType>(lambder)`) and keep the
|
|
52
52
|
* cheap type check that interface exists for.
|
|
53
53
|
*/
|
|
@@ -59,17 +59,17 @@ export type LambderTestedInstance<TSessionData, TContract> = Lambder<TSessionDat
|
|
|
59
59
|
* stores put under it in place, and as many simulated browsers in front of it
|
|
60
60
|
* as a test needs. No HTTP, no AWS, and nothing in the app restructured.
|
|
61
61
|
*
|
|
62
|
-
* The app's own declarations all run as written:
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
62
|
+
* The app's own declarations all run as written: guards, named rate-limit
|
|
63
|
+
* policies, idempotency settings, session salt, cookie options and
|
|
64
|
+
* dataRefresh, hooks and error handlers. Only where things rest is replaced:
|
|
65
|
+
* once this is created, the stores the app was configured with are out of the
|
|
66
|
+
* instance's reach, so a test cannot touch a production table by mistake.
|
|
67
|
+
* What the app reaches on its own (its database, a mailer) is the app's to
|
|
68
|
+
* replace.
|
|
69
69
|
*
|
|
70
70
|
* The sibling of LambderMockApp, which serves a contract from mock handlers:
|
|
71
71
|
* the same verbs (`signIn`, `signOut`, `expireSessionData`, `reset`) over the
|
|
72
|
-
* real handlers
|
|
72
|
+
* real handlers.
|
|
73
73
|
*
|
|
74
74
|
* Time is not this class's: fake `Date` with the test runner
|
|
75
75
|
* (`vi.useFakeTimers({ toFake: ["Date"] })`), which moves the framework, the
|
|
@@ -97,22 +97,20 @@ export declare class LambderTestApp<TContract extends LambderApiContractShape =
|
|
|
97
97
|
private resetCount;
|
|
98
98
|
private readonly crashList;
|
|
99
99
|
/**
|
|
100
|
-
* The call a crash happened under. The app
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
* async context says which call a crash belongs to.
|
|
100
|
+
* The call a crash happened under. The app's 500 says nothing about the
|
|
101
|
+
* crash, so the error travels beside the answer, and with concurrent
|
|
102
|
+
* calls (a duplicate sent while the original is in flight is an ordinary
|
|
103
|
+
* idempotency test) only the async context says which call it belongs to.
|
|
105
104
|
*/
|
|
106
105
|
private readonly crashScope;
|
|
107
106
|
constructor(lambder: LambderTestedInstance<TSessionData, TContract>, options?: LambderTestAppOptions);
|
|
108
107
|
/**
|
|
109
108
|
* Every error the app threw while answering a request since the last
|
|
110
109
|
* reset, in order: what reached its global error handler, or the
|
|
111
|
-
* framework's last-resort 500. The answers
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* with, which the test already holds.
|
|
110
|
+
* framework's last-resort 500. The answers say nothing about what was
|
|
111
|
+
* thrown, so a test reads it here; `expect(app.crashes).toEqual([])`
|
|
112
|
+
* says nothing crashed. A refusal is not a crash, and neither is an
|
|
113
|
+
* error an `event()` rejects with, which the test already holds.
|
|
116
114
|
*/
|
|
117
115
|
get crashes(): readonly Error[];
|
|
118
116
|
/** The session manager, for tests that inspect or manipulate sessions directly. Throws when the app has no sessions. */
|
|
@@ -12,17 +12,17 @@ import { LambderTestVisitor } from "./LambderTestVisitor.js";
|
|
|
12
12
|
* stores put under it in place, and as many simulated browsers in front of it
|
|
13
13
|
* as a test needs. No HTTP, no AWS, and nothing in the app restructured.
|
|
14
14
|
*
|
|
15
|
-
* The app's own declarations all run as written:
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
15
|
+
* The app's own declarations all run as written: guards, named rate-limit
|
|
16
|
+
* policies, idempotency settings, session salt, cookie options and
|
|
17
|
+
* dataRefresh, hooks and error handlers. Only where things rest is replaced:
|
|
18
|
+
* once this is created, the stores the app was configured with are out of the
|
|
19
|
+
* instance's reach, so a test cannot touch a production table by mistake.
|
|
20
|
+
* What the app reaches on its own (its database, a mailer) is the app's to
|
|
21
|
+
* replace.
|
|
22
22
|
*
|
|
23
23
|
* The sibling of LambderMockApp, which serves a contract from mock handlers:
|
|
24
24
|
* the same verbs (`signIn`, `signOut`, `expireSessionData`, `reset`) over the
|
|
25
|
-
* real handlers
|
|
25
|
+
* real handlers.
|
|
26
26
|
*
|
|
27
27
|
* Time is not this class's: fake `Date` with the test runner
|
|
28
28
|
* (`vi.useFakeTimers({ toFake: ["Date"] })`), which moves the framework, the
|
|
@@ -50,16 +50,15 @@ export class LambderTestApp {
|
|
|
50
50
|
resetCount = 0;
|
|
51
51
|
crashList = [];
|
|
52
52
|
/**
|
|
53
|
-
* The call a crash happened under. The app
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* async context says which call a crash belongs to.
|
|
53
|
+
* The call a crash happened under. The app's 500 says nothing about the
|
|
54
|
+
* crash, so the error travels beside the answer, and with concurrent
|
|
55
|
+
* calls (a duplicate sent while the original is in flight is an ordinary
|
|
56
|
+
* idempotency test) only the async context says which call it belongs to.
|
|
58
57
|
*/
|
|
59
58
|
crashScope = new AsyncLocalStorage();
|
|
60
59
|
constructor(lambder, options = {}) {
|
|
61
|
-
// Only a Lambder instance
|
|
62
|
-
// key
|
|
60
|
+
// Only a Lambder instance from this same package copy answers to the
|
|
61
|
+
// key; saying so here beats a bare "is not a function".
|
|
63
62
|
if (typeof lambder?.[LAMBDER_BACKEND_SWAP] !== "function") {
|
|
64
63
|
throw new Error("lambderTestApp: expected a Lambder instance (what initLambder().create() returns), from the same installed copy of lambder as lambder/testing.");
|
|
65
64
|
}
|
|
@@ -94,11 +93,11 @@ export class LambderTestApp {
|
|
|
94
93
|
return { result: await this.crashScope.run(scope, run), crash: scope.crash };
|
|
95
94
|
},
|
|
96
95
|
issueSession: async (host, sessionKey, data, ttlSeconds) => {
|
|
97
|
-
// Through a session controller on a request context,
|
|
98
|
-
// login handler does
|
|
99
|
-
//
|
|
96
|
+
// Through a session controller on a request context, as a
|
|
97
|
+
// login handler does, so the cookies are what the app's own
|
|
98
|
+
// cookie options produce for that host.
|
|
100
99
|
const event = synthesizeLambdaHttpEvent({ method: "GET", path: "/", host }, { invoke: false });
|
|
101
|
-
const ctx = createContext(event, localLambdaContext("lambder-test"), lambder.apiPath);
|
|
100
|
+
const ctx = createContext(event, localLambdaContext("lambder-test"), { apiPath: lambder.apiPath });
|
|
102
101
|
const created = await lambder.getSessionController(ctx).issueSession(sessionKey, data, ttlSeconds);
|
|
103
102
|
const headers = {};
|
|
104
103
|
ctx.responseHeaders.applyInto(headers);
|
|
@@ -109,11 +108,10 @@ export class LambderTestApp {
|
|
|
109
108
|
/**
|
|
110
109
|
* Every error the app threw while answering a request since the last
|
|
111
110
|
* reset, in order: what reached its global error handler, or the
|
|
112
|
-
* framework's last-resort 500. The answers
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
* with, which the test already holds.
|
|
111
|
+
* framework's last-resort 500. The answers say nothing about what was
|
|
112
|
+
* thrown, so a test reads it here; `expect(app.crashes).toEqual([])`
|
|
113
|
+
* says nothing crashed. A refusal is not a crash, and neither is an
|
|
114
|
+
* error an `event()` rejects with, which the test already holds.
|
|
117
115
|
*/
|
|
118
116
|
get crashes() {
|
|
119
117
|
return this.crashList;
|