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