lambder 7.3.1 → 8.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1047 -3
- package/README.md +46 -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/ContractTypePrinter.d.ts +85 -0
- package/dist/build/ContractTypePrinter.js +402 -0
- package/dist/build/freshProcessVerifier.d.ts +13 -0
- package/dist/build/freshProcessVerifier.js +19 -0
- package/dist/build/moduleLocation.d.ts +11 -0
- package/dist/build/moduleLocation.js +6 -0
- package/dist/build/writeApiContract.d.ts +78 -0
- package/dist/build/writeApiContract.js +302 -0
- package/dist/build/writeApiSignatures.d.ts +114 -0
- package/dist/build/writeApiSignatures.js +217 -0
- package/dist/build/writeFileAtomically.d.ts +8 -0
- package/dist/build/writeFileAtomically.js +22 -0
- package/dist/build.d.ts +14 -0
- package/dist/build.js +11 -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/LambderUploadRunner.d.ts +96 -0
- package/dist/client/LambderUploadRunner.js +234 -0
- package/dist/client/lambderFetchTransport.d.ts +4 -1
- package/dist/client/lambderFetchTransport.js +52 -28
- package/dist/client.d.ts +9 -3
- package/dist/client.js +6 -1
- package/dist/core/Lambder.d.ts +143 -79
- package/dist/core/Lambder.js +350 -231
- 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 +25 -8
- package/dist/index.js +13 -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 +43 -33
- package/dist/mock/lambderMockMswHandler.js +50 -39
- package/dist/mock/lambderMockUploadMswHandler.d.ts +26 -0
- package/dist/mock/lambderMockUploadMswHandler.js +28 -0
- package/dist/mock.d.ts +4 -1
- package/dist/mock.js +6 -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/contracts/LambderUploadBucket.d.ts +154 -0
- package/dist/shared/contracts/LambderUploadBucket.js +74 -0
- 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/LambderContentDisposition.d.ts +10 -0
- package/dist/shared/util/LambderContentDisposition.js +13 -0
- 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/LambderTextDigest.d.ts +7 -5
- package/dist/shared/util/LambderTextDigest.js +11 -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 +98 -53
- package/dist/shared/wire/LambderApiOutcome.d.ts +43 -31
- package/dist/shared/wire/LambderApiOutcome.js +48 -23
- package/dist/shared/wire/LambderApiRefusal.d.ts +45 -27
- package/dist/shared/wire/LambderApiRefusal.js +42 -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/shared/wire/LambderUploadObjectFields.d.ts +10 -0
- package/dist/shared/wire/LambderUploadObjectFields.js +24 -0
- package/dist/shared/wire/LambderUploadRefusal.d.ts +9 -0
- package/dist/shared/wire/LambderUploadRefusal.js +18 -0
- package/dist/shared/wire/LambderUploadSchemas.d.ts +12 -0
- package/dist/shared/wire/LambderUploadSchemas.js +30 -0
- 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 +80 -38
- 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/LambderMemoryUploadBucket.d.ts +99 -0
- package/dist/stores/LambderMemoryUploadBucket.js +219 -0
- package/dist/stores/LambderS3FileSource.d.ts +21 -6
- package/dist/stores/LambderS3FileSource.js +12 -7
- package/dist/stores/LambderS3UploadBucket.d.ts +73 -0
- package/dist/stores/LambderS3UploadBucket.js +144 -0
- package/dist/stores/LambderSdkInstallHint.d.ts +11 -0
- package/dist/stores/LambderSdkInstallHint.js +14 -0
- package/dist/testing/LambderTestApp.d.ts +23 -25
- 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 +3 -0
- package/dist/testing.js +2 -0
- package/package.json +26 -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
package/dist/core/Lambder.js
CHANGED
|
@@ -2,9 +2,11 @@ import LambderResolver from "./LambderResolver.js";
|
|
|
2
2
|
import LambderResponseBuilder from "./LambderResponseBuilder.js";
|
|
3
3
|
import { LambderResponse, finalizeResponse, answerFromResponse, responseFromAnswer, emitResponse, DEFAULT_FINALIZE_OPTIONS, DEFAULT_RESPONSE_COMPRESSION_SETTINGS, } from "./LambderResponse.js";
|
|
4
4
|
import { compileRouteMatcher } from "./LambderRouting.js";
|
|
5
|
-
import { applyCorsHeaders } from "./LambderCors.js";
|
|
5
|
+
import { allowedCorsOriginOf, applyCorsHeaders } from "./LambderCors.js";
|
|
6
6
|
import LambderSessionManager from "../session/LambderSessionManager.js";
|
|
7
7
|
import { resolveCompressionOption } from "../shared/wire/LambderCompressionOption.js";
|
|
8
|
+
import { DEFAULT_API_PATH } from "../shared/wire/LambderDefaultApiPath.js";
|
|
9
|
+
import { LambderSessionNotFoundError } from "../session/LambderSessionController.js";
|
|
8
10
|
import { LambderPublicFilesHandler } from "./LambderPublicFiles.js";
|
|
9
11
|
import { LambderIndexHtmlHandler } from "./LambderIndexHtml.js";
|
|
10
12
|
import { LambderFiles } from "./LambderFiles.js";
|
|
@@ -13,10 +15,12 @@ import { LambderApiPipeline } from "../api/LambderApiPipeline.js";
|
|
|
13
15
|
import { LAMBDER_BACKEND_SWAP, LAMBDER_CRASH_WATCH } from "../shared/util/LambderTestingDoors.js";
|
|
14
16
|
import { apiSignatureOf } from "../api/LambderApiSignature.js";
|
|
15
17
|
import { apiNameKeyOf } from "../shared/wire/LambderApiSignature.js";
|
|
16
|
-
import { apiNotFoundAnswer,
|
|
17
|
-
import { createContext, isV2HttpEvent } from "./LambderContext.js";
|
|
18
|
+
import { apiNotFoundAnswer, refusalAnswer, sessionExpiredAnswer, } from "../api/LambderApiEnvelope.js";
|
|
19
|
+
import { bindContextTools, createContext, isV2HttpEvent, } from "./LambderContext.js";
|
|
18
20
|
import { COMPRESSED_PAYLOAD_GZ_FIELD, COMPRESSED_PAYLOAD_BR_FIELD, COMPRESSED_PAYLOAD_BYTES_FIELD } from "../shared/wire/LambderRequestPayload.js";
|
|
19
21
|
import { coerceToError } from "../shared/wire/LambderCrashDetail.js";
|
|
22
|
+
import { LambderCrashHandling } from "./LambderCrashHandling.js";
|
|
23
|
+
import { policyBuildersFor } from "./LambderPolicyBuilders.js";
|
|
20
24
|
import { assertCreateOptions, } from "./LambderCreateOptions.js";
|
|
21
25
|
/**
|
|
22
26
|
* Main Lambder class for building type-safe serverless APIs. Create
|
|
@@ -31,7 +35,7 @@ import { assertCreateOptions, } from "./LambderCreateOptions.js";
|
|
|
31
35
|
* @typeParam _TIdempotencyEnabled - @internal True when create() received idempotency (do not pass manually)
|
|
32
36
|
* @typeParam _TSessionGuardsRequired - @internal True when create() received requireSessionApiGuards (do not pass manually)
|
|
33
37
|
* @typeParam _TPublicGuardsRequired - @internal True when create() received requirePublicApiGuards (do not pass manually)
|
|
34
|
-
* @typeParam _TSessionsEnabled - @internal True when create() received the session option (do not pass manually).
|
|
38
|
+
* @typeParam _TSessionsEnabled - @internal True when create() received the session option (do not pass manually). Defaults to true, unlike its siblings, so a plugin annotating its parameter as the bare Lambder<SessionData> can still register session APIs. create() knows the option and supplies the false; `new Lambder(...)` relies on the registration-time throw alone.
|
|
35
39
|
*
|
|
36
40
|
* @example
|
|
37
41
|
* ```typescript
|
|
@@ -53,20 +57,19 @@ export default class Lambder {
|
|
|
53
57
|
/** The instance's file reader (source + caches), or null without the files option. */
|
|
54
58
|
files;
|
|
55
59
|
/**
|
|
56
|
-
* Type property for extracting the API contract
|
|
57
|
-
*
|
|
60
|
+
* Type property for extracting the API contract: every registered API's
|
|
61
|
+
* input, output, mode and declared options, as a client calls it.
|
|
58
62
|
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* See LambderFlattenContract for the measurements.
|
|
63
|
+
* A small app's client imports it as it is. It is an intersection one
|
|
64
|
+
* member deep per endpoint, so reading it generically costs a large app's
|
|
65
|
+
* client most of its type check; writeApiContract (lambder/build) reads
|
|
66
|
+
* this property off the exported instance and writes the contract out as
|
|
67
|
+
* plain types for such a client to import instead.
|
|
65
68
|
*
|
|
66
69
|
* @example
|
|
67
70
|
* ```typescript
|
|
68
|
-
* const lambder =
|
|
69
|
-
* export
|
|
71
|
+
* export const lambder = initLambder().create({ ... }).addApi(...).addApi(...);
|
|
72
|
+
* export type ApiContractType = typeof lambder.ApiContract;
|
|
70
73
|
* ```
|
|
71
74
|
*/
|
|
72
75
|
ApiContract;
|
|
@@ -93,17 +96,24 @@ export default class Lambder {
|
|
|
93
96
|
requireSessionApiGuards;
|
|
94
97
|
/** Told what a request threw, beside whatever answers it; null outside a test. See LAMBDER_CRASH_WATCH. */
|
|
95
98
|
crashWatcher = null;
|
|
99
|
+
/** The crashes option applied: reporting, and the framework's own 500. */
|
|
100
|
+
crashHandling;
|
|
101
|
+
/** What this instance binds onto every context it renders (ctx.sessionController, ctx.rateLimit, ctx.isRateLimited). */
|
|
102
|
+
contextTools;
|
|
96
103
|
trustedClientIpHeaders;
|
|
104
|
+
trustedHostHeaders;
|
|
97
105
|
requirePublicApiGuards;
|
|
98
106
|
constructor(options = {}) {
|
|
99
107
|
assertCreateOptions(options);
|
|
100
108
|
this.files = options.files ? new LambderFiles(options.files) : null;
|
|
101
|
-
this.apiPath = options.apiPath ??
|
|
109
|
+
this.apiPath = options.apiPath ?? DEFAULT_API_PATH;
|
|
102
110
|
this.apiVersion = options.apiVersion ?? null;
|
|
111
|
+
// Resolved (and validated) by the same function the at-rest stores
|
|
112
|
+
// use; on unless explicitly disabled, except on a REST API, where it
|
|
113
|
+
// is on only when the app names it: see LambderFinalizeOptions.
|
|
114
|
+
const compression = resolveCompressionOption(options.compression, DEFAULT_RESPONSE_COMPRESSION_SETTINGS);
|
|
103
115
|
this.finalizeOptions = {
|
|
104
|
-
|
|
105
|
-
// stores use; on unless explicitly disabled.
|
|
106
|
-
compression: resolveCompressionOption(options.compression, DEFAULT_RESPONSE_COMPRESSION_SETTINGS),
|
|
116
|
+
compression: { v1: options.compression === undefined ? null : compression, v2: compression },
|
|
107
117
|
etag: options.etag ?? DEFAULT_FINALIZE_OPTIONS.etag,
|
|
108
118
|
maxResponseBytes: options.maxResponseBytes ?? DEFAULT_FINALIZE_OPTIONS.maxResponseBytes,
|
|
109
119
|
};
|
|
@@ -140,8 +150,34 @@ export default class Lambder {
|
|
|
140
150
|
idempotency: options.idempotency,
|
|
141
151
|
});
|
|
142
152
|
this.trustedClientIpHeaders = options.trustedClientIpHeaders ?? [];
|
|
153
|
+
this.trustedHostHeaders = options.trustedHostHeaders ?? [];
|
|
143
154
|
this.requireSessionApiGuards = options.requireSessionApiGuards ?? false;
|
|
144
155
|
this.requirePublicApiGuards = options.requirePublicApiGuards ?? false;
|
|
156
|
+
this.crashHandling = new LambderCrashHandling(options.crashes ?? {}, this.apiVersion);
|
|
157
|
+
this.contextTools = {
|
|
158
|
+
sessionControllerFor: (ctx) => this.getSessionController(ctx),
|
|
159
|
+
chargeRateLimit: async (ctx, policy, key, refuse) => {
|
|
160
|
+
const { checkResult, refusal } = await this.pipeline.chargeRateLimit(policy, {
|
|
161
|
+
// A per-API budget counts per registered API. The posted
|
|
162
|
+
// name of a call no API matched (a hook or the fallback
|
|
163
|
+
// charging it) is the caller's choice, and a fresh name
|
|
164
|
+
// per request would be a fresh counter.
|
|
165
|
+
apiName: ctx.api && this.apiDefinitions.has(ctx.api.apiName) ? ctx.api.apiName : null,
|
|
166
|
+
ip: ctx.ip,
|
|
167
|
+
session: ctx.session,
|
|
168
|
+
key,
|
|
169
|
+
});
|
|
170
|
+
if (refuse && refusal) {
|
|
171
|
+
if (ctx.api)
|
|
172
|
+
throw refusal;
|
|
173
|
+
// A route has no envelope to carry a refusal, so it
|
|
174
|
+
// answers the same 429 as text, with the same Retry-After
|
|
175
|
+
// and the policy's own words.
|
|
176
|
+
throw this.getResolver(ctx).text(refusal.errorMessage.content, { statusCode: 429, headers: refusal.headers });
|
|
177
|
+
}
|
|
178
|
+
return checkResult;
|
|
179
|
+
},
|
|
180
|
+
};
|
|
145
181
|
}
|
|
146
182
|
// =====================================================================
|
|
147
183
|
// Registration
|
|
@@ -163,7 +199,13 @@ export default class Lambder {
|
|
|
163
199
|
this.globalErrorHandler = globalErrorHandler;
|
|
164
200
|
return this;
|
|
165
201
|
}
|
|
166
|
-
/**
|
|
202
|
+
/**
|
|
203
|
+
* Response for a session route when the session is missing or expired,
|
|
204
|
+
* and for any non-API request whose route or hook meets a
|
|
205
|
+
* LambderSessionNotFoundError (the session ended while the request held
|
|
206
|
+
* it, or a session read found none, or cookies naming several: a
|
|
207
|
+
* LambderSessionAmbiguousError). Default: 401.
|
|
208
|
+
*/
|
|
167
209
|
setSessionExpiredRouteHandler(handler) {
|
|
168
210
|
this.sessionExpiredRouteHandler = handler;
|
|
169
211
|
return this;
|
|
@@ -173,10 +215,10 @@ export default class Lambder {
|
|
|
173
215
|
* never shadow routes registered after it. Serves files from the `files`
|
|
174
216
|
* source configured at creation, under the reader's path rule, mime-typed,
|
|
175
217
|
* memory-cached, with the immutable-cache heuristic for content-hashed
|
|
176
|
-
* assets. Only configured methods reach it
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
218
|
+
* assets. Only configured methods reach it (default GET/HEAD); a
|
|
219
|
+
* gated-out method or a path with no file falls through to
|
|
220
|
+
* setRouteFallbackHandler, where the app decides what remains (e.g.
|
|
221
|
+
* render an app shell with res.templateFile).
|
|
180
222
|
*/
|
|
181
223
|
servePublicFiles(options = {}) {
|
|
182
224
|
if (!this.files)
|
|
@@ -218,29 +260,51 @@ export default class Lambder {
|
|
|
218
260
|
}
|
|
219
261
|
// Typed API with Zod
|
|
220
262
|
addApi(name, schema, handler) {
|
|
221
|
-
this.
|
|
222
|
-
const definition = { name, mode: "public", guards: schema.guards, rateLimit: schema.rateLimit, idempotency: schema.idempotency, input: schema.input, output: schema.output };
|
|
223
|
-
this.apiDefinitions.set(name, definition);
|
|
224
|
-
this.actionList.push({
|
|
225
|
-
match: (ctx) => ctx.apiName === name ? {} : false,
|
|
226
|
-
actionFn: (ctx, resolver) => this.runApi(ctx, resolver, definition, handler),
|
|
227
|
-
});
|
|
263
|
+
this.registerApi(name, "public", schema, handler);
|
|
228
264
|
return this;
|
|
229
265
|
}
|
|
230
266
|
// Typed Session API with Zod
|
|
231
267
|
addSessionApi(name, schema, handler) {
|
|
232
|
-
this.
|
|
233
|
-
|
|
268
|
+
this.registerApi(name, "session", schema, handler);
|
|
269
|
+
return this;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* What registering an API is, for addApi and addSessionApi alike: the
|
|
273
|
+
* checks that can refuse it, then its definition recorded (what
|
|
274
|
+
* apiSignatures() digests) and its action appended to the first-match
|
|
275
|
+
* chain. The two public methods differ only in the mode and in the types
|
|
276
|
+
* they give the handler.
|
|
277
|
+
*/
|
|
278
|
+
registerApi(name, mode, schema, handler) {
|
|
279
|
+
if (this.apiDefinitions.has(name)) {
|
|
280
|
+
throw new Error(`Lambder: duplicate API name "${name}". Dispatch is first-match, so the second registration would be silently dead code.`);
|
|
281
|
+
}
|
|
282
|
+
// Everything that can refuse the registration runs before the name is
|
|
283
|
+
// claimed below: a refusal the app catches and fixes would otherwise
|
|
284
|
+
// leave the name taken, and the retry would report a duplicate
|
|
285
|
+
// instead of the problem it was fixing.
|
|
286
|
+
if (mode === "session" && !this.pipeline.hasSessions) {
|
|
287
|
+
throw new Error(`Lambder: session API "${name}" needs the session option at creation.`);
|
|
288
|
+
}
|
|
289
|
+
const guardsRequired = mode === "session" ? this.requireSessionApiGuards : this.requirePublicApiGuards;
|
|
290
|
+
if (guardsRequired && schema.guards === undefined) {
|
|
291
|
+
const optOut = mode === "session"
|
|
292
|
+
? "the named no-op guard that marks the session itself as the whole authorization"
|
|
293
|
+
: "the named no-op guard that records why anyone may call it";
|
|
294
|
+
throw new Error(`Lambder: ${mode} API "${name}" declares no guards, and require${mode === "session" ? "Session" : "Public"}ApiGuards is on. ` +
|
|
295
|
+
`Declare the guard that authorizes it, or ${optOut}.`);
|
|
296
|
+
}
|
|
297
|
+
const definition = { name, mode, guards: schema.guards, rateLimit: schema.rateLimit, idempotency: schema.idempotency, input: schema.input, output: schema.output };
|
|
298
|
+
this.pipeline.assertRegistration(definition);
|
|
234
299
|
this.apiDefinitions.set(name, definition);
|
|
235
300
|
this.actionList.push({
|
|
236
301
|
match: (ctx) => ctx.apiName === name ? {} : false,
|
|
237
|
-
actionFn: (ctx
|
|
302
|
+
actionFn: (ctx) => this.runApi(ctx, definition, handler),
|
|
238
303
|
});
|
|
239
|
-
return this;
|
|
240
304
|
}
|
|
241
305
|
addHook(hookEvent, hookFn, priority = 0) {
|
|
242
306
|
if (hookEvent === "created") {
|
|
243
|
-
// Runs once, lazily,
|
|
307
|
+
// Runs once, lazily, before the first request or event is handled.
|
|
244
308
|
this.createdHooks.push(hookFn);
|
|
245
309
|
}
|
|
246
310
|
else {
|
|
@@ -272,11 +336,10 @@ export default class Lambder {
|
|
|
272
336
|
// The policy generics are `any` in the plugin signature on purpose: a
|
|
273
337
|
// module may annotate its parameter as the bare Lambder<SessionData> or
|
|
274
338
|
// as the app's narrowed alias, and both must chain. Registration-time
|
|
275
|
-
// assertions still
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
// its own plugins.
|
|
339
|
+
// assertions still check every referenced policy/guard name. Every
|
|
340
|
+
// policy generic must be listed: a missing one falls back to its default,
|
|
341
|
+
// making an instance with a non-default value unassignable to its own
|
|
342
|
+
// plugins.
|
|
280
343
|
use(plugin) {
|
|
281
344
|
return plugin(this);
|
|
282
345
|
}
|
|
@@ -285,9 +348,11 @@ export default class Lambder {
|
|
|
285
348
|
// What a handler or an app asks the instance for.
|
|
286
349
|
// =====================================================================
|
|
287
350
|
/**
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
* presents cookies alone.
|
|
351
|
+
* A session controller for a context: what creates, rotates, refreshes
|
|
352
|
+
* and ends sessions. An API call presents its posted CSRF token; a route
|
|
353
|
+
* presents cookies alone. A context this instance renders already
|
|
354
|
+
* carries one as `ctx.sessionController`; this is for a context it did
|
|
355
|
+
* not render, such as one createContext() built from an event on its own.
|
|
291
356
|
*/
|
|
292
357
|
getSessionController(ctx) {
|
|
293
358
|
const context = ctx;
|
|
@@ -320,26 +385,24 @@ export default class Lambder {
|
|
|
320
385
|
/**
|
|
321
386
|
* Every registered endpoint's signature, keyed by its hashed name: the
|
|
322
387
|
* LambderApiSignatureMap both sides ship with. A generator imports the
|
|
323
|
-
* finished instance, awaits this, and writes
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
* itself. Keys are sorted, so the generated file diffs by endpoint.
|
|
388
|
+
* finished instance, awaits this, and writes a file that LambderCaller
|
|
389
|
+
* and create() both take as apiSignatures; at request time the pipeline
|
|
390
|
+
* compares a call's signature with the server's copy. This is the only
|
|
391
|
+
* place a digest is computed, so there is no second computation to drift
|
|
392
|
+
* from it. Keys are sorted, so the generated file diffs by endpoint.
|
|
329
393
|
*/
|
|
330
394
|
async apiSignatures() {
|
|
331
395
|
return Object.fromEntries((await this.apiSignatureEntries()).map(({ key, signature }) => [key, signature]));
|
|
332
396
|
}
|
|
333
397
|
/**
|
|
334
|
-
* The same signatures
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
* Reading this instead, it can name them.
|
|
398
|
+
* The same signatures, sorted by key as the map is, with the endpoint
|
|
399
|
+
* name each was digested from. The map a client ships deliberately lists
|
|
400
|
+
* no names, so a generator reading only the map could say how many
|
|
401
|
+
* signatures changed but not which endpoints; this lets it name them.
|
|
339
402
|
*
|
|
340
|
-
* A build-time view
|
|
341
|
-
*
|
|
342
|
-
*
|
|
403
|
+
* A build-time view: it comes off the server instance, which a generator
|
|
404
|
+
* imports and a client never does, so nothing here reaches a bundle
|
|
405
|
+
* unless the generator writes it there.
|
|
343
406
|
*/
|
|
344
407
|
async apiSignatureEntries() {
|
|
345
408
|
const entries = await Promise.all([...this.apiDefinitions.values()].map(async (definition) => ({
|
|
@@ -392,32 +455,35 @@ export default class Lambder {
|
|
|
392
455
|
})();
|
|
393
456
|
this.initPromise = pending;
|
|
394
457
|
// A `created` hook usually reaches something that can be briefly
|
|
395
|
-
// unavailable (a first DynamoDB read, a secret fetch).
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
458
|
+
// unavailable (a first DynamoDB read, a secret fetch). A kept
|
|
459
|
+
// rejection would answer every later invocation on the warm
|
|
460
|
+
// container with that first failure, so it is forgotten and the
|
|
461
|
+
// next invocation runs the hooks again. Whoever is awaiting this
|
|
462
|
+
// one still gets the rejection.
|
|
400
463
|
pending.catch(() => { if (this.initPromise === pending)
|
|
401
464
|
this.initPromise = null; });
|
|
402
465
|
}
|
|
403
466
|
return this.initPromise;
|
|
404
467
|
}
|
|
405
|
-
applyCors(
|
|
406
|
-
applyCorsHeaders(this.corsConfig,
|
|
468
|
+
applyCors(allowedOrigin, response, isPreflight) {
|
|
469
|
+
applyCorsHeaders(this.corsConfig, allowedOrigin, response, isPreflight);
|
|
407
470
|
}
|
|
408
471
|
/**
|
|
409
472
|
* The beforeRender hooks, in priority order: the replaced context to
|
|
410
473
|
* continue with, or the response one of them answered with.
|
|
411
474
|
*
|
|
412
|
-
* Its own method because
|
|
413
|
-
* match, it
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
475
|
+
* Its own method because both request paths run it. Run only after a
|
|
476
|
+
* match, it would skip every servePublicFiles and serveIndexHtml answer
|
|
477
|
+
* (every asset and app-shell page): a security header written in a hook
|
|
478
|
+
* would miss the HTML it was written for, and a maintenance-mode hook
|
|
479
|
+
* would still serve the whole frontend.
|
|
480
|
+
*
|
|
481
|
+
* Each replacement is also handed to `onContextReplaced` as it is made,
|
|
482
|
+
* rather than only returned: render() answers from it after the handler
|
|
483
|
+
* too (the afterRender hooks, a crash's report, reveal and global error
|
|
484
|
+
* handler), and a handler or a later hook that throws returns nothing.
|
|
419
485
|
*/
|
|
420
|
-
async runBeforeRenderHooks(ctx, resolver) {
|
|
486
|
+
async runBeforeRenderHooks(ctx, resolver, onContextReplaced) {
|
|
421
487
|
let currentCtx = ctx;
|
|
422
488
|
for (const hook of this.hookList["beforeRender"]) {
|
|
423
489
|
const hookResult = await hook.hookFn(currentCtx, resolver);
|
|
@@ -425,14 +491,20 @@ export default class Lambder {
|
|
|
425
491
|
throw hookResult;
|
|
426
492
|
if (hookResult instanceof LambderResponse)
|
|
427
493
|
return hookResult;
|
|
428
|
-
|
|
494
|
+
if (hookResult === currentCtx)
|
|
495
|
+
continue;
|
|
496
|
+
// A hook that answered with a new object (`{ ...ctx, extra }`)
|
|
497
|
+
// carries none of the tools, which are not enumerable, so they are
|
|
498
|
+
// bound again, onto the object the rest of the request uses.
|
|
499
|
+
currentCtx = bindContextTools(hookResult, this.contextTools);
|
|
500
|
+
onContextReplaced(currentCtx);
|
|
429
501
|
}
|
|
430
502
|
return currentCtx;
|
|
431
503
|
}
|
|
432
|
-
async handleNoMatchedAction(ctx, resolver) {
|
|
504
|
+
async handleNoMatchedAction(ctx, resolver, onContextReplaced) {
|
|
433
505
|
// Before the fallback hooks, and with the same power it has on a
|
|
434
506
|
// matched route: the fallback hooks are typed void and cannot answer.
|
|
435
|
-
const beforeRenderResult = await this.runBeforeRenderHooks(ctx, resolver);
|
|
507
|
+
const beforeRenderResult = await this.runBeforeRenderHooks(ctx, resolver, onContextReplaced);
|
|
436
508
|
if (beforeRenderResult instanceof LambderResponse)
|
|
437
509
|
return beforeRenderResult;
|
|
438
510
|
const currentCtx = beforeRenderResult;
|
|
@@ -443,7 +515,7 @@ export default class Lambder {
|
|
|
443
515
|
if (isAPI) {
|
|
444
516
|
if (this.apiFallbackHandler)
|
|
445
517
|
return await this.apiFallbackHandler(currentCtx, resolver);
|
|
446
|
-
return responseFromAnswer(currentCtx.api ? this.pipeline.answerUnknownApi(currentCtx
|
|
518
|
+
return responseFromAnswer(currentCtx.api ? this.pipeline.answerUnknownApi(currentCtx) : apiNotFoundAnswer(this.apiVersion, currentCtx.logList));
|
|
447
519
|
}
|
|
448
520
|
if (this.publicFilesHandler) {
|
|
449
521
|
const fileResponse = await this.publicFilesHandler.handle(currentCtx);
|
|
@@ -459,16 +531,16 @@ export default class Lambder {
|
|
|
459
531
|
}
|
|
460
532
|
/**
|
|
461
533
|
* True for the OPTIONS request the CORS layer answers by itself. Asked
|
|
462
|
-
* twice: once to build the 204, once at the end of render() to
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
534
|
+
* twice: once to build the 204, once at the end of render() to pick which
|
|
535
|
+
* form of the headers goes on. Applying the ordinary headers on top of
|
|
536
|
+
* the 204's would put both forms on a preflight: `Vary: Origin, Origin`
|
|
537
|
+
* and an Access-Control-Expose-Headers that means nothing before a
|
|
538
|
+
* request.
|
|
467
539
|
*/
|
|
468
540
|
isCorsPreflight(ctx) {
|
|
469
541
|
return ctx.method === "OPTIONS" && !!this.corsConfig;
|
|
470
542
|
}
|
|
471
|
-
async resolveRequest(ctx, resolver) {
|
|
543
|
+
async resolveRequest(ctx, resolver, onContextReplaced) {
|
|
472
544
|
if (this.isCorsPreflight(ctx))
|
|
473
545
|
return new LambderResponse({ statusCode: 204, body: null });
|
|
474
546
|
if (ctx.api) {
|
|
@@ -496,44 +568,51 @@ export default class Lambder {
|
|
|
496
568
|
}
|
|
497
569
|
}
|
|
498
570
|
if (!matched)
|
|
499
|
-
return await this.handleNoMatchedAction(ctx, resolver);
|
|
571
|
+
return await this.handleNoMatchedAction(ctx, resolver, onContextReplaced);
|
|
500
572
|
// Set before the hooks run, so a beforeRender hook on a matched route
|
|
501
573
|
// sees the route's own path params.
|
|
502
574
|
ctx.pathParams = matched.params;
|
|
503
|
-
const beforeRenderResult = await this.runBeforeRenderHooks(ctx, resolver);
|
|
575
|
+
const beforeRenderResult = await this.runBeforeRenderHooks(ctx, resolver, onContextReplaced);
|
|
504
576
|
if (beforeRenderResult instanceof LambderResponse)
|
|
505
577
|
return beforeRenderResult;
|
|
506
578
|
return await matched.action.actionFn(beforeRenderResult, resolver);
|
|
507
579
|
}
|
|
508
580
|
async render(event, lambdaContext) {
|
|
509
581
|
let ctx = null;
|
|
582
|
+
let started = false;
|
|
583
|
+
// Settled as soon as the context exists and reused by every answer,
|
|
584
|
+
// the crash path's included; see allowedCorsOriginOf.
|
|
585
|
+
let allowedOrigin = null;
|
|
510
586
|
try {
|
|
511
587
|
await this.ensureInitialized();
|
|
512
|
-
|
|
588
|
+
started = true;
|
|
589
|
+
ctx = bindContextTools(createContext(event, lambdaContext, {
|
|
590
|
+
apiPath: this.apiPath,
|
|
591
|
+
trustedClientIpHeaders: this.trustedClientIpHeaders,
|
|
592
|
+
trustedHostHeaders: this.trustedHostHeaders,
|
|
593
|
+
}), this.contextTools);
|
|
594
|
+
if (this.corsConfig)
|
|
595
|
+
allowedOrigin = allowedCorsOriginOf(this.corsConfig, ctx);
|
|
513
596
|
const resolver = this.getResolver(ctx);
|
|
514
597
|
let response;
|
|
515
598
|
try {
|
|
516
|
-
|
|
599
|
+
// A context a beforeRender hook hands back is the request's
|
|
600
|
+
// from then on, here as in the handler: the afterRender hooks,
|
|
601
|
+
// a thrown answer and a crash's report, reveal and global
|
|
602
|
+
// error handler all read what the hook added, and the session
|
|
603
|
+
// a session route or API read onto it.
|
|
604
|
+
response = await this.resolveRequest(ctx, resolver, (replacement) => { ctx = replacement; });
|
|
517
605
|
}
|
|
518
606
|
catch (err) {
|
|
519
|
-
|
|
520
|
-
if (err instanceof LambderResponse) {
|
|
521
|
-
response = err;
|
|
522
|
-
}
|
|
523
|
-
// A thrown LambderApiRefusal on an API call IS a structured refusal
|
|
524
|
-
// (brand-checked, not instanceof, to survive duplicate installs).
|
|
525
|
-
else if (isLambderApiRefusal(err) && ctx.api) {
|
|
526
|
-
response = this.apiErrorResponse(err, ctx);
|
|
527
|
-
}
|
|
528
|
-
else {
|
|
529
|
-
throw err;
|
|
530
|
-
}
|
|
607
|
+
response = await this.answerThrown(err, ctx, resolver);
|
|
531
608
|
}
|
|
532
|
-
//
|
|
609
|
+
// Everything from here writes into the response, and the object a
|
|
610
|
+
// handler answered with may be one it keeps between requests.
|
|
611
|
+
response = response.copy();
|
|
612
|
+
// What the call wrote goes on before the hooks run, so an
|
|
533
613
|
// afterRender hook can override or delete a header the handler
|
|
534
|
-
// wrote
|
|
535
|
-
//
|
|
536
|
-
// was the last to speak in.
|
|
614
|
+
// wrote. Applied afterwards, it would put the handler's value
|
|
615
|
+
// straight back over the hook's.
|
|
537
616
|
const responseIntoHooks = response;
|
|
538
617
|
const headersAppliedIntoHooks = ctx.responseHeaders.size;
|
|
539
618
|
ctx.responseHeaders.applyTo(response);
|
|
@@ -542,143 +621,183 @@ export default class Lambder {
|
|
|
542
621
|
const hookResponse = await hook.hookFn(ctx, resolver, response);
|
|
543
622
|
if (hookResponse instanceof Error)
|
|
544
623
|
throw hookResponse;
|
|
545
|
-
response
|
|
624
|
+
// A response a hook answers with may be one it keeps
|
|
625
|
+
// between requests, like a handler's, and the hooks after
|
|
626
|
+
// it write into it: copied for the same reason.
|
|
627
|
+
if (hookResponse !== response)
|
|
628
|
+
response = hookResponse.copy();
|
|
546
629
|
}
|
|
547
630
|
}
|
|
548
631
|
catch (err) {
|
|
549
|
-
|
|
550
|
-
response = err;
|
|
551
|
-
}
|
|
552
|
-
else if (isLambderApiRefusal(err) && ctx.api) {
|
|
553
|
-
response = this.apiErrorResponse(err, ctx);
|
|
554
|
-
}
|
|
555
|
-
else {
|
|
556
|
-
throw err;
|
|
557
|
-
}
|
|
632
|
+
response = (await this.answerThrown(err, ctx, resolver)).copy();
|
|
558
633
|
}
|
|
559
634
|
// Only what the hooks themselves wrote (res.setHeader inside a
|
|
560
|
-
// hook) is left to apply, which
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
//
|
|
635
|
+
// hook) is left to apply, which leaves their overrides standing.
|
|
636
|
+
// A hook that answered with a different response takes the whole
|
|
637
|
+
// set instead: headers belong to the call, not to the response
|
|
638
|
+
// that first carried them, so the call's session cookie must
|
|
639
|
+
// reach it.
|
|
565
640
|
ctx.responseHeaders.applyTo(response, response === responseIntoHooks ? headersAppliedIntoHooks : 0);
|
|
566
|
-
this.applyCors(
|
|
641
|
+
this.applyCors(allowedOrigin, response, this.isCorsPreflight(ctx));
|
|
567
642
|
return await finalizeResponse(ctx, response, this.finalizeOptions, ctx.eventFormat);
|
|
568
643
|
}
|
|
569
644
|
catch (err) {
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
645
|
+
return await this.answerCrash(err, ctx, allowedOrigin, started, event, lambdaContext);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* A thrown value that is an answer rather than a crash, as the response;
|
|
650
|
+
* anything else is rethrown to the crash path.
|
|
651
|
+
*
|
|
652
|
+
* - A LambderResponse IS the response (res.die.*, throw res.html(...)).
|
|
653
|
+
* - A LambderApiRefusal on an API call is its structured refusal
|
|
654
|
+
* (brand-checked, not instanceof, to survive duplicate installs).
|
|
655
|
+
* - A LambderSessionNotFoundError is a missing session: one that ended
|
|
656
|
+
* while the request held it (a logout or a password change landing
|
|
657
|
+
* mid-request), or one a route or hook asked for that the request
|
|
658
|
+
* never had or whose cookies named several (its subclass
|
|
659
|
+
* LambderSessionAmbiguousError). It is answered the way a missing
|
|
660
|
+
* session is answered here, the decision the API pipeline makes for a
|
|
661
|
+
* handler, applied to the routes and hooks it never sees.
|
|
662
|
+
*/
|
|
663
|
+
async answerThrown(thrown, ctx, resolver) {
|
|
664
|
+
if (thrown instanceof LambderResponse)
|
|
665
|
+
return thrown;
|
|
666
|
+
if (isLambderApiRefusal(thrown) && ctx.api)
|
|
667
|
+
return this.apiErrorResponse(thrown, ctx);
|
|
668
|
+
if (thrown instanceof LambderSessionNotFoundError)
|
|
669
|
+
return await this.sessionMissingResponse(ctx, resolver);
|
|
670
|
+
throw thrown;
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* A crash, from the thrown value to the answer. It is told to the test
|
|
674
|
+
* watch and reported before anything answers, so the report depends on
|
|
675
|
+
* nothing the answer might break; then the app's global error handler
|
|
676
|
+
* answers it, or the framework's own 500 does when there is none or it
|
|
677
|
+
* failed too.
|
|
678
|
+
*/
|
|
679
|
+
async answerCrash(thrown, ctx, allowedOrigin, started, event, lambdaContext) {
|
|
680
|
+
// Describing the thrown value can itself throw (a null-prototype
|
|
681
|
+
// object, a Proxy, a throwing toString/Symbol.toPrimitive). Unguarded,
|
|
682
|
+
// the crash path would throw too, no handler would run, and the
|
|
683
|
+
// invocation would reject with a 502 no client can parse.
|
|
684
|
+
const error = coerceToError(thrown, "an unstringifiable thrown value");
|
|
685
|
+
this.crashWatcher?.(error);
|
|
686
|
+
const site = !started ? { kind: "startup", lambdaContext }
|
|
687
|
+
: ctx?.api ? { kind: "api", ctx, lambdaContext }
|
|
688
|
+
: { kind: "route", ctx, lambdaContext };
|
|
689
|
+
await this.crashHandling.report(error, site);
|
|
690
|
+
// ctx may be null (createContext failed): derive the format from the raw event.
|
|
691
|
+
const eventFormat = ctx?.eventFormat ?? (isV2HttpEvent(event) ? "v2" : "v1");
|
|
692
|
+
let errorHandlerCrash = null;
|
|
693
|
+
try {
|
|
694
|
+
if (this.globalErrorHandler) {
|
|
695
|
+
const errorResponse = await this.globalErrorHandler(error, ctx, this.getResponseBuilder(ctx ?? undefined));
|
|
696
|
+
return await finalizeResponse(ctx, this.withCallHeaders(ctx, allowedOrigin, errorResponse), this.finalizeOptions, eventFormat);
|
|
596
697
|
}
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
try {
|
|
603
|
-
return await finalizeResponse(ctx, handlerErr, this.finalizeOptions, eventFormat);
|
|
604
|
-
}
|
|
605
|
-
catch { /* fall through */ }
|
|
698
|
+
}
|
|
699
|
+
catch (handlerErr) {
|
|
700
|
+
if (handlerErr instanceof LambderResponse) {
|
|
701
|
+
try {
|
|
702
|
+
return await finalizeResponse(ctx, this.withCallHeaders(ctx, allowedOrigin, handlerErr), this.finalizeOptions, eventFormat);
|
|
606
703
|
}
|
|
704
|
+
catch { /* fall through */ }
|
|
705
|
+
}
|
|
706
|
+
else {
|
|
707
|
+
// A second crash, in the code meant to answer the first:
|
|
708
|
+
// reported in its own right, with the thrown value as its
|
|
709
|
+
// cause, so neither of the two disappears.
|
|
710
|
+
errorHandlerCrash = new Error("Lambder: the global error handler threw while answering a crash.", {
|
|
711
|
+
cause: coerceToError(handlerErr, "an unstringifiable thrown value"),
|
|
712
|
+
});
|
|
713
|
+
await this.crashHandling.report(errorHandlerCrash, site);
|
|
607
714
|
}
|
|
608
|
-
// Last-resort 500. API calls get the core's crash envelope so
|
|
609
|
-
// clients can parse a structured failure; everything else keeps
|
|
610
|
-
// plain text. Emitted directly rather than finalized, because
|
|
611
|
-
// finalization may be what failed. The headers still go on: they
|
|
612
|
-
// belong to the call and not to the response that first carried
|
|
613
|
-
// them, so a call that wrote a session cookie and then threw still
|
|
614
|
-
// owes the browser that cookie, and a cross-origin caller cannot
|
|
615
|
-
// read this error at all without the CORS headers. Applying them
|
|
616
|
-
// is plain object work, none of the compression, base64 or size
|
|
617
|
-
// handling that finalization does.
|
|
618
|
-
const crashResponse = ctx?.api
|
|
619
|
-
? responseFromAnswer(crashAnswer(this.apiVersion))
|
|
620
|
-
: new LambderResponse({ statusCode: 500, body: "Internal Server Error." });
|
|
621
|
-
ctx?.responseHeaders.applyTo(crashResponse);
|
|
622
|
-
if (ctx)
|
|
623
|
-
this.applyCors(ctx, crashResponse, false);
|
|
624
|
-
return emitResponse(eventFormat, crashResponse.statusCode, crashResponse.headers, typeof crashResponse.body === "string" ? crashResponse.body : "", false);
|
|
625
715
|
}
|
|
716
|
+
// Emitted directly rather than finalized, because finalization may be
|
|
717
|
+
// what failed; applying the call's headers is plain object work, none
|
|
718
|
+
// of the compression, base64 or size handling finalization does.
|
|
719
|
+
const crashResponse = this.withCallHeaders(ctx, allowedOrigin, await this.crashHandling.frameworkResponse(error, ctx, errorHandlerCrash));
|
|
720
|
+
return emitResponse(eventFormat, crashResponse.statusCode, crashResponse.headers, typeof crashResponse.body === "string" ? crashResponse.body : "", false);
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* An answer to a crash, carrying what the call wrote and its CORS headers.
|
|
724
|
+
* As on the success path, headers belong to the call: a call that wrote a
|
|
725
|
+
* session cookie and then threw still owes the browser that cookie, and a
|
|
726
|
+
* cross-origin caller cannot read the error at all without CORS headers.
|
|
727
|
+
* The CORS verdict is the one the request settled before it crashed, so
|
|
728
|
+
* answering a crash runs none of the app's code.
|
|
729
|
+
*/
|
|
730
|
+
withCallHeaders(ctx, allowedOrigin, answer) {
|
|
731
|
+
// A copy, as on the success path: an error handler may answer with an object it keeps.
|
|
732
|
+
const response = answer.copy();
|
|
733
|
+
if (!ctx)
|
|
734
|
+
return response;
|
|
735
|
+
ctx.responseHeaders.applyTo(response);
|
|
736
|
+
this.applyCors(allowedOrigin, response, false);
|
|
737
|
+
return response;
|
|
626
738
|
}
|
|
627
739
|
/**
|
|
628
740
|
* Fetch the session for a session route or short-circuit it with the
|
|
629
|
-
*
|
|
630
|
-
*
|
|
631
|
-
*
|
|
741
|
+
* answer for a missing session. Session APIs never come through here:
|
|
742
|
+
* the pipeline answers them with the protocol's { sessionExpired: true }
|
|
743
|
+
* envelope itself.
|
|
632
744
|
*/
|
|
633
745
|
async requireSession(ctx, resolver) {
|
|
634
746
|
const session = await this.getSessionController(ctx).fetchSessionIfExists();
|
|
635
|
-
if (!session)
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
747
|
+
if (!session)
|
|
748
|
+
throw await this.sessionMissingResponse(ctx, resolver);
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* The answer to a request that needed a session and has none, whether it
|
|
752
|
+
* never had one or it ended while the request held it: an API call gets
|
|
753
|
+
* the protocol's sessionExpired envelope, as the pipeline gives a session
|
|
754
|
+
* API, and anything else the setSessionExpiredRouteHandler answer, a 401
|
|
755
|
+
* by default.
|
|
756
|
+
*/
|
|
757
|
+
async sessionMissingResponse(ctx, resolver) {
|
|
758
|
+
if (ctx.api)
|
|
759
|
+
return responseFromAnswer(sessionExpiredAnswer(this.apiVersion, ctx.logList));
|
|
760
|
+
if (!this.sessionExpiredRouteHandler)
|
|
761
|
+
return resolver.status(401, "Session required.");
|
|
762
|
+
try {
|
|
763
|
+
return await this.sessionExpiredRouteHandler(ctx, resolver);
|
|
764
|
+
}
|
|
765
|
+
catch (err) {
|
|
766
|
+
// It may answer by throwing, as any handler may.
|
|
767
|
+
if (err instanceof LambderResponse)
|
|
768
|
+
return err;
|
|
769
|
+
throw err;
|
|
640
770
|
}
|
|
641
771
|
}
|
|
642
|
-
/**
|
|
772
|
+
/**
|
|
773
|
+
* Dispatch a non-HTTP Lambda event to the registered actions. What an
|
|
774
|
+
* action throws is reported (crashes.report) and then rethrown untouched,
|
|
775
|
+
* so Lambda's retries and dead-letter queues still see the failure.
|
|
776
|
+
*/
|
|
643
777
|
async renderEvent(event, lambdaContext) {
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
778
|
+
let started = false;
|
|
779
|
+
try {
|
|
780
|
+
await this.ensureInitialized();
|
|
781
|
+
started = true;
|
|
782
|
+
for (const action of this.eventActionList) {
|
|
783
|
+
if (action.match(event)) {
|
|
784
|
+
return await action.actionFn(event, lambdaContext);
|
|
785
|
+
}
|
|
648
786
|
}
|
|
787
|
+
const summary = event && typeof event === "object"
|
|
788
|
+
? ` (source: ${String(event.source ?? "?")}, detail-type: ${String(event["detail-type"] ?? "?")})`
|
|
789
|
+
: "";
|
|
790
|
+
throw new Error(`Lambder: no action matched non-HTTP event${summary}. Register one with addAction(); a trailing addAction(() => true, ...) acts as a fallback.`);
|
|
791
|
+
}
|
|
792
|
+
catch (err) {
|
|
793
|
+
await this.crashHandling.report(coerceToError(err, "an unstringifiable thrown value"), started ? { kind: "event", event, lambdaContext } : { kind: "startup", lambdaContext });
|
|
794
|
+
throw err;
|
|
649
795
|
}
|
|
650
|
-
const summary = event && typeof event === "object"
|
|
651
|
-
? ` (source: ${String(event.source ?? "?")}, detail-type: ${String(event["detail-type"] ?? "?")})`
|
|
652
|
-
: "";
|
|
653
|
-
throw new Error(`Lambder: no action matched non-HTTP event${summary}. Register one with addAction(); a trailing addAction(() => true, ...) acts as a fallback.`);
|
|
654
796
|
}
|
|
655
797
|
// =====================================================================
|
|
656
798
|
// The API path
|
|
657
799
|
// The steps only an API call takes, around the shared core.
|
|
658
800
|
// =====================================================================
|
|
659
|
-
/** Registration-time checks shared by addApi/addSessionApi. */
|
|
660
|
-
assertApiRegistration(name, mode, options) {
|
|
661
|
-
if (this.apiDefinitions.has(name)) {
|
|
662
|
-
throw new Error(`Lambder: duplicate API name "${name}". Dispatch is first-match, so the second registration would be silently dead code.`);
|
|
663
|
-
}
|
|
664
|
-
// Everything that can refuse this registration runs before the name is
|
|
665
|
-
// claimed. Claiming it first meant a caught registration error burned
|
|
666
|
-
// the name, and the retry reported a duplicate instead of the problem
|
|
667
|
-
// the app was fixing; the session check was still on the far side of
|
|
668
|
-
// that line, one method down in addSessionApi.
|
|
669
|
-
if (mode === "session" && !this.pipeline.hasSessions) {
|
|
670
|
-
throw new Error(`Lambder: session API "${name}" needs the session option at creation.`);
|
|
671
|
-
}
|
|
672
|
-
const guardsRequired = mode === "session" ? this.requireSessionApiGuards : this.requirePublicApiGuards;
|
|
673
|
-
if (guardsRequired && options.guards === undefined) {
|
|
674
|
-
const optOut = mode === "session"
|
|
675
|
-
? "the named no-op guard that marks the session itself as the whole authorization"
|
|
676
|
-
: "the named no-op guard that records why anyone may call it";
|
|
677
|
-
throw new Error(`Lambder: ${mode} API "${name}" declares no guards, and require${mode === "session" ? "Session" : "Public"}ApiGuards is on. ` +
|
|
678
|
-
`Declare the guard that authorizes it, or ${optOut}.`);
|
|
679
|
-
}
|
|
680
|
-
this.pipeline.assertRegistration({ name, mode, guards: options.guards, rateLimit: options.rateLimit, idempotency: options.idempotency });
|
|
681
|
-
}
|
|
682
801
|
/**
|
|
683
802
|
* The answer for a rejected input: the app's
|
|
684
803
|
* setApiInputValidationErrorHandler when set, otherwise the standard 422
|
|
@@ -699,12 +818,14 @@ export default class Lambder {
|
|
|
699
818
|
* via res.die.*) becomes the answer the pipeline stores and hands back.
|
|
700
819
|
* The context is the pipeline's context, so a session it fetched is on
|
|
701
820
|
* ctx.session and the validated payload is on ctx.apiPayload when the
|
|
702
|
-
* handler runs.
|
|
821
|
+
* handler runs. The handler's resolver knows the API's output schema, so
|
|
822
|
+
* every success payload is parsed through it before it is sent.
|
|
703
823
|
*/
|
|
704
|
-
async runApi(ctx,
|
|
824
|
+
async runApi(ctx, definition, handler) {
|
|
705
825
|
const request = ctx.api;
|
|
706
826
|
if (!request)
|
|
707
827
|
throw new Error(`Lambder: API "${definition.name}" was matched by a request that is not an API call.`);
|
|
828
|
+
const resolver = new LambderResolver({ files: this.files, apiVersion: this.apiVersion, ctx, apiOutput: definition.output });
|
|
708
829
|
// What the handler produced, in both forms: the answer went to the
|
|
709
830
|
// pipeline, and the response is kept so it can carry on unchanged.
|
|
710
831
|
const handled = { output: null };
|
|
@@ -725,13 +846,13 @@ export default class Lambder {
|
|
|
725
846
|
handled.output = { response, answer: answerFromResponse(response) };
|
|
726
847
|
return handled.output.answer;
|
|
727
848
|
});
|
|
728
|
-
//
|
|
729
|
-
//
|
|
730
|
-
//
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
//
|
|
849
|
+
// When the pipeline answered with the handler's own answer, its
|
|
850
|
+
// response carries on rather than a rebuild. An answer holds a Buffer
|
|
851
|
+
// body base64-encoded (the plain shape the idempotency store
|
|
852
|
+
// persists), and a response rebuilt from it would hand finalization a
|
|
853
|
+
// base64 string it must pass through uncompressed. Identity decides,
|
|
854
|
+
// since the pipeline may have answered with a stored replay or a
|
|
855
|
+
// refusal instead.
|
|
735
856
|
if (handled.output?.answer === answer)
|
|
736
857
|
return handled.output.response;
|
|
737
858
|
return responseFromAnswer(answer);
|
|
@@ -743,11 +864,10 @@ export default class Lambder {
|
|
|
743
864
|
}
|
|
744
865
|
/**
|
|
745
866
|
* The canonical way to create an instance: fix the session data type first,
|
|
746
|
-
* then create with the full configuration in one declaration
|
|
747
|
-
* guard
|
|
748
|
-
*
|
|
749
|
-
*
|
|
750
|
-
* ordering rules and nothing can be half-configured.
|
|
867
|
+
* then create with the full configuration in one declaration. The policy,
|
|
868
|
+
* guard and idempotency types are inferred from the options, so the instance
|
|
869
|
+
* is born fully typed and `typeof lambderApp` is the annotation type for api
|
|
870
|
+
* modules. There are no ordering rules, and nothing can be half-configured.
|
|
751
871
|
*
|
|
752
872
|
* ```typescript
|
|
753
873
|
* // app.ts (imports no api modules, so modules can import the type back)
|
|
@@ -768,15 +888,14 @@ export default class Lambder {
|
|
|
768
888
|
* export const handler = lambder.getHandler();
|
|
769
889
|
* ```
|
|
770
890
|
*
|
|
771
|
-
*
|
|
772
|
-
* `new Lambder<S>(...)`
|
|
773
|
-
*
|
|
774
|
-
*
|
|
775
|
-
*
|
|
776
|
-
* infer everything else from the options. `new Lambder(options)` remains
|
|
777
|
-
* for untyped or session-data-free instances.
|
|
891
|
+
* Curried because TypeScript type arguments are all-or-nothing per call:
|
|
892
|
+
* passing the session data type to `new Lambder<S>(...)` would silently
|
|
893
|
+
* widen the inferred policy and guard types to their {} defaults. Fixing the
|
|
894
|
+
* session type in the first call lets the second infer everything else.
|
|
895
|
+
* `new Lambder(options)` serves untyped or session-data-free instances.
|
|
778
896
|
*/
|
|
779
897
|
export const initLambder = () => ({
|
|
898
|
+
...policyBuildersFor(),
|
|
780
899
|
create(options) {
|
|
781
900
|
return new Lambder(options);
|
|
782
901
|
},
|