lambder 7.3.1 → 8.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +933 -3
- package/README.md +41 -21
- package/dist/api/LambderApiAnswer.d.ts +18 -22
- package/dist/api/LambderApiAnswer.js +6 -7
- package/dist/api/LambderApiCallContext.d.ts +21 -8
- package/dist/api/LambderApiCallContext.js +22 -4
- package/dist/api/LambderApiDefinition.d.ts +4 -3
- package/dist/api/LambderApiEnvelope.d.ts +14 -9
- package/dist/api/LambderApiEnvelope.js +33 -34
- package/dist/api/LambderApiGuards.d.ts +78 -51
- package/dist/api/LambderApiGuards.js +34 -36
- package/dist/api/LambderApiIdempotency.d.ts +68 -62
- package/dist/api/LambderApiIdempotency.js +214 -151
- package/dist/api/LambderApiOutputValidationError.d.ts +32 -0
- package/dist/api/LambderApiOutputValidationError.js +50 -0
- package/dist/api/LambderApiPipeline.d.ts +47 -38
- package/dist/api/LambderApiPipeline.js +122 -63
- package/dist/api/LambderApiRateLimits.d.ts +201 -54
- package/dist/api/LambderApiRateLimits.js +185 -108
- package/dist/api/LambderApiRequest.d.ts +27 -21
- package/dist/api/LambderApiRequest.js +26 -19
- package/dist/api/LambderApiSignature.d.ts +12 -15
- package/dist/api/LambderApiSignature.js +28 -51
- package/dist/api/LambderApiValidationRefusal.d.ts +9 -9
- package/dist/api/LambderApiValidationRefusal.js +10 -10
- package/dist/build/freshProcessVerifier.d.ts +13 -0
- package/dist/build/freshProcessVerifier.js +19 -0
- package/dist/build/writeApiSignatures.d.ts +109 -0
- package/dist/build/writeApiSignatures.js +222 -0
- package/dist/build.d.ts +9 -0
- package/dist/build.js +8 -0
- package/dist/client/LambderCaller.d.ts +13 -44
- package/dist/client/LambderCaller.js +77 -84
- package/dist/client/LambderReloadLoopBreaker.d.ts +56 -26
- package/dist/client/LambderReloadLoopBreaker.js +90 -46
- package/dist/client/lambderFetchTransport.d.ts +4 -1
- package/dist/client/lambderFetchTransport.js +52 -28
- package/dist/client.d.ts +5 -3
- package/dist/client.js +2 -1
- package/dist/core/Lambder.d.ts +140 -75
- package/dist/core/Lambder.js +347 -227
- package/dist/core/LambderContext.d.ts +82 -15
- package/dist/core/LambderContext.js +107 -20
- package/dist/core/LambderCors.d.ts +21 -3
- package/dist/core/LambderCors.js +35 -16
- package/dist/core/LambderCrashHandling.d.ts +40 -0
- package/dist/core/LambderCrashHandling.js +97 -0
- package/dist/core/LambderCreateOptions.d.ts +151 -75
- package/dist/core/LambderCreateOptions.js +16 -23
- package/dist/core/LambderFiles.d.ts +21 -7
- package/dist/core/LambderFiles.js +62 -34
- package/dist/core/LambderIndexHtml.js +12 -11
- package/dist/core/LambderPolicyBuilders.d.ts +17 -5
- package/dist/core/LambderPolicyBuilders.js +17 -5
- package/dist/core/LambderPublicFiles.d.ts +11 -5
- package/dist/core/LambderPublicFiles.js +32 -4
- package/dist/core/LambderRequestPath.d.ts +43 -0
- package/dist/core/LambderRequestPath.js +63 -0
- package/dist/core/LambderResponse.d.ts +26 -5
- package/dist/core/LambderResponse.js +157 -70
- package/dist/core/LambderResponseBuilder.d.ts +49 -4
- package/dist/core/LambderResponseBuilder.js +64 -3
- package/dist/core/LambderRouting.d.ts +2 -3
- package/dist/core/LambderRouting.js +22 -7
- package/dist/core/LambderTemplatingEngine.js +211 -32
- package/dist/index.d.ts +15 -8
- package/dist/index.js +5 -4
- package/dist/invoke/LambderInvokeCaller.d.ts +37 -42
- package/dist/invoke/LambderInvokeCaller.js +76 -66
- package/dist/invoke/LambderInvokeOutcome.d.ts +27 -26
- package/dist/invoke/LambderInvokeOutcome.js +9 -22
- package/dist/invoke/LambderLambdaEvent.d.ts +29 -9
- package/dist/invoke/LambderLambdaEvent.js +40 -22
- package/dist/invoke/lambderHandlerTransport.d.ts +9 -10
- package/dist/invoke/lambderHandlerTransport.js +15 -18
- package/dist/mock/LambderMockApp.d.ts +67 -83
- package/dist/mock/LambderMockApp.js +167 -153
- package/dist/mock/LambderMockBrowserCookies.d.ts +24 -28
- package/dist/mock/LambderMockBrowserCookies.js +24 -28
- package/dist/mock/LambderMockCallRecorder.d.ts +15 -22
- package/dist/mock/LambderMockCallRecorder.js +19 -28
- package/dist/mock/LambderMockCreateOptions.d.ts +42 -24
- package/dist/mock/LambderMockEntryRegistry.d.ts +11 -12
- package/dist/mock/LambderMockEntryRegistry.js +24 -29
- package/dist/mock/LambderMockFailureInjector.d.ts +3 -6
- package/dist/mock/LambderMockFailureInjector.js +3 -6
- package/dist/mock/LambderMockTypes.d.ts +78 -108
- package/dist/mock/lambderMockInvokeTransport.d.ts +11 -13
- package/dist/mock/lambderMockInvokeTransport.js +11 -10
- package/dist/mock/lambderMockMswHandler.d.ts +33 -29
- package/dist/mock/lambderMockMswHandler.js +50 -39
- package/dist/mock.d.ts +1 -1
- package/dist/mock.js +2 -3
- package/dist/session/LambderSessionController.d.ts +108 -89
- package/dist/session/LambderSessionController.js +187 -168
- package/dist/session/LambderSessionCrypto.d.ts +16 -7
- package/dist/session/LambderSessionCrypto.js +26 -12
- package/dist/session/LambderSessionManager.d.ts +124 -46
- package/dist/session/LambderSessionManager.js +262 -137
- package/dist/shared/LambderHtml.d.ts +42 -3
- package/dist/shared/LambderHtml.js +127 -7
- package/dist/shared/LambderHtmlPositions.d.ts +173 -0
- package/dist/shared/LambderHtmlPositions.js +652 -0
- package/dist/shared/LambderI18n.d.ts +10 -11
- package/dist/shared/LambderI18n.js +33 -21
- package/dist/shared/contracts/LambderCache.d.ts +66 -0
- package/dist/shared/contracts/LambderCache.js +11 -0
- package/dist/shared/contracts/LambderFileSource.d.ts +6 -6
- package/dist/shared/contracts/LambderFileSource.js +5 -8
- package/dist/shared/contracts/LambderIdempotencyStore.d.ts +51 -22
- package/dist/shared/contracts/LambderIdempotencyStore.js +4 -5
- package/dist/shared/contracts/LambderRateLimiter.d.ts +27 -15
- package/dist/shared/contracts/LambderRateLimiter.js +4 -5
- package/dist/shared/contracts/LambderSessionStore.d.ts +65 -26
- package/dist/shared/contracts/LambderSessionStore.js +5 -6
- package/dist/shared/transport/LambderApiTransport.d.ts +27 -27
- package/dist/shared/transport/LambderApiTransport.js +7 -7
- package/dist/shared/transport/LambderCookieJar.d.ts +28 -35
- package/dist/shared/transport/LambderCookieJar.js +54 -66
- package/dist/shared/transport/lambderCookieJarTransport.d.ts +11 -13
- package/dist/shared/transport/lambderCookieJarTransport.js +24 -23
- package/dist/shared/util/LambderCallAbort.d.ts +5 -5
- package/dist/shared/util/LambderCallAbort.js +5 -5
- package/dist/shared/util/LambderClientIp.d.ts +27 -11
- package/dist/shared/util/LambderClientIp.js +96 -13
- package/dist/shared/util/LambderExpiringMap.d.ts +35 -49
- package/dist/shared/util/LambderExpiringMap.js +41 -57
- package/dist/shared/util/LambderNodeModules.js +6 -7
- package/dist/shared/util/LambderOptionChecks.d.ts +4 -4
- package/dist/shared/util/LambderOptionChecks.js +4 -4
- package/dist/shared/util/LambderResponseBrand.d.ts +5 -5
- package/dist/shared/util/LambderResponseBrand.js +5 -5
- package/dist/shared/util/LambderTypeUtilities.d.ts +7 -8
- package/dist/shared/util/LambderTypeUtilities.js +3 -3
- package/dist/shared/util/boundKeyField.d.ts +20 -0
- package/dist/shared/util/boundKeyField.js +34 -0
- package/dist/shared/util/canonicalJson.d.ts +11 -0
- package/dist/shared/util/canonicalJson.js +28 -0
- package/dist/shared/util/joinKeyFields.d.ts +20 -0
- package/dist/shared/util/joinKeyFields.js +22 -0
- package/dist/shared/wire/LambderAnswerHeaders.d.ts +12 -16
- package/dist/shared/wire/LambderAnswerHeaders.js +12 -16
- package/dist/shared/wire/LambderApiContract.d.ts +107 -32
- package/dist/shared/wire/LambderApiOutcome.d.ts +43 -31
- package/dist/shared/wire/LambderApiOutcome.js +48 -23
- package/dist/shared/wire/LambderApiRefusal.d.ts +39 -27
- package/dist/shared/wire/LambderApiRefusal.js +36 -7
- package/dist/shared/wire/LambderApiSignature.d.ts +18 -22
- package/dist/shared/wire/LambderApiSignature.js +16 -19
- package/dist/shared/wire/LambderCallOptions.d.ts +38 -47
- package/dist/shared/wire/LambderCallOptions.js +9 -11
- package/dist/shared/wire/LambderCompressionCodec.d.ts +29 -34
- package/dist/shared/wire/LambderCompressionCodec.js +31 -36
- package/dist/shared/wire/LambderCompressionOption.d.ts +9 -9
- package/dist/shared/wire/LambderCompressionOption.js +9 -9
- package/dist/shared/wire/LambderCrashDetail.d.ts +12 -15
- package/dist/shared/wire/LambderCrashDetail.js +12 -15
- package/dist/shared/wire/LambderDefaultApiPath.d.ts +6 -0
- package/dist/shared/wire/LambderDefaultApiPath.js +6 -0
- package/dist/shared/wire/LambderHttpStatus.d.ts +6 -7
- package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +89 -0
- package/dist/shared/wire/LambderIdempotencyKeyScope.js +146 -0
- package/dist/shared/wire/LambderInvokeApiId.d.ts +27 -0
- package/dist/shared/wire/LambderInvokeApiId.js +27 -0
- package/dist/shared/wire/LambderOutcomeAssertions.d.ts +6 -7
- package/dist/shared/wire/LambderOutcomeAssertions.js +6 -7
- package/dist/shared/wire/LambderRequestPayload.d.ts +18 -20
- package/dist/shared/wire/LambderRequestPayload.js +4 -6
- package/dist/stores/LambderCacheFiller.d.ts +48 -0
- package/dist/stores/LambderCacheFiller.js +119 -0
- package/dist/stores/LambderCacheKeys.d.ts +26 -0
- package/dist/stores/LambderCacheKeys.js +54 -0
- package/dist/stores/LambderCacheValues.d.ts +45 -0
- package/dist/stores/LambderCacheValues.js +74 -0
- package/dist/stores/LambderDdbCache.d.ts +121 -56
- package/dist/stores/LambderDdbCache.js +528 -225
- package/dist/stores/LambderDdbIdempotencyStore.d.ts +33 -22
- package/dist/stores/LambderDdbIdempotencyStore.js +75 -50
- package/dist/stores/LambderDdbRateLimiter.d.ts +76 -20
- package/dist/stores/LambderDdbRateLimiter.js +151 -39
- package/dist/stores/LambderDdbSdk.d.ts +43 -31
- package/dist/stores/LambderDdbSdk.js +79 -33
- package/dist/stores/LambderDdbSessionStore.d.ts +27 -14
- package/dist/stores/LambderDdbSessionStore.js +119 -47
- package/dist/stores/LambderHttpFileSource.d.ts +15 -6
- package/dist/stores/LambderHttpFileSource.js +15 -13
- package/dist/stores/LambderMemoryCache.d.ts +49 -0
- package/dist/stores/LambderMemoryCache.js +113 -0
- package/dist/stores/LambderMemoryIdempotencyStore.d.ts +13 -12
- package/dist/stores/LambderMemoryIdempotencyStore.js +31 -30
- package/dist/stores/LambderMemoryRateLimiter.d.ts +8 -9
- package/dist/stores/LambderMemoryRateLimiter.js +14 -13
- package/dist/stores/LambderMemorySessionStore.d.ts +14 -11
- package/dist/stores/LambderMemorySessionStore.js +38 -19
- package/dist/stores/LambderS3FileSource.d.ts +21 -6
- package/dist/stores/LambderS3FileSource.js +12 -7
- package/dist/testing/LambderTestApp.d.ts +21 -23
- package/dist/testing/LambderTestApp.js +22 -24
- package/dist/testing/LambderTestVisitor.d.ts +10 -12
- package/dist/testing/LambderTestVisitor.js +15 -15
- package/dist/testing.d.ts +1 -0
- package/dist/testing.js +1 -0
- package/package.json +12 -3
- package/dist/api/LambderApiPolicyEngine.d.ts +0 -47
- package/dist/api/LambderApiPolicyEngine.js +0 -85
- package/dist/shared/util/LambderKeyFields.d.ts +0 -32
- package/dist/shared/util/LambderKeyFields.js +0 -34
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,15 +57,15 @@ 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, to export your API
|
|
61
|
+
* types to the frontend.
|
|
58
62
|
*
|
|
59
63
|
* Export it as an interface extending LambderFlattenContract, not as a
|
|
60
64
|
* type alias. Chaining builds the contract as an intersection one member
|
|
61
|
-
* deep per endpoint
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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.
|
|
65
69
|
*
|
|
66
70
|
* @example
|
|
67
71
|
* ```typescript
|
|
@@ -93,17 +97,24 @@ export default class Lambder {
|
|
|
93
97
|
requireSessionApiGuards;
|
|
94
98
|
/** Told what a request threw, beside whatever answers it; null outside a test. See LAMBDER_CRASH_WATCH. */
|
|
95
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;
|
|
96
104
|
trustedClientIpHeaders;
|
|
105
|
+
trustedHostHeaders;
|
|
97
106
|
requirePublicApiGuards;
|
|
98
107
|
constructor(options = {}) {
|
|
99
108
|
assertCreateOptions(options);
|
|
100
109
|
this.files = options.files ? new LambderFiles(options.files) : null;
|
|
101
|
-
this.apiPath = options.apiPath ??
|
|
110
|
+
this.apiPath = options.apiPath ?? DEFAULT_API_PATH;
|
|
102
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);
|
|
103
116
|
this.finalizeOptions = {
|
|
104
|
-
|
|
105
|
-
// stores use; on unless explicitly disabled.
|
|
106
|
-
compression: resolveCompressionOption(options.compression, DEFAULT_RESPONSE_COMPRESSION_SETTINGS),
|
|
117
|
+
compression: { v1: options.compression === undefined ? null : compression, v2: compression },
|
|
107
118
|
etag: options.etag ?? DEFAULT_FINALIZE_OPTIONS.etag,
|
|
108
119
|
maxResponseBytes: options.maxResponseBytes ?? DEFAULT_FINALIZE_OPTIONS.maxResponseBytes,
|
|
109
120
|
};
|
|
@@ -140,8 +151,34 @@ export default class Lambder {
|
|
|
140
151
|
idempotency: options.idempotency,
|
|
141
152
|
});
|
|
142
153
|
this.trustedClientIpHeaders = options.trustedClientIpHeaders ?? [];
|
|
154
|
+
this.trustedHostHeaders = options.trustedHostHeaders ?? [];
|
|
143
155
|
this.requireSessionApiGuards = options.requireSessionApiGuards ?? false;
|
|
144
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
|
+
};
|
|
145
182
|
}
|
|
146
183
|
// =====================================================================
|
|
147
184
|
// Registration
|
|
@@ -163,7 +200,13 @@ export default class Lambder {
|
|
|
163
200
|
this.globalErrorHandler = globalErrorHandler;
|
|
164
201
|
return this;
|
|
165
202
|
}
|
|
166
|
-
/**
|
|
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
|
+
*/
|
|
167
210
|
setSessionExpiredRouteHandler(handler) {
|
|
168
211
|
this.sessionExpiredRouteHandler = handler;
|
|
169
212
|
return this;
|
|
@@ -173,10 +216,10 @@ export default class Lambder {
|
|
|
173
216
|
* never shadow routes registered after it. Serves files from the `files`
|
|
174
217
|
* source configured at creation, under the reader's path rule, mime-typed,
|
|
175
218
|
* memory-cached, with the immutable-cache heuristic for content-hashed
|
|
176
|
-
* assets. Only configured methods reach it
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
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).
|
|
180
223
|
*/
|
|
181
224
|
servePublicFiles(options = {}) {
|
|
182
225
|
if (!this.files)
|
|
@@ -218,29 +261,51 @@ export default class Lambder {
|
|
|
218
261
|
}
|
|
219
262
|
// Typed API with Zod
|
|
220
263
|
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
|
-
});
|
|
264
|
+
this.registerApi(name, "public", schema, handler);
|
|
228
265
|
return this;
|
|
229
266
|
}
|
|
230
267
|
// Typed Session API with Zod
|
|
231
268
|
addSessionApi(name, schema, handler) {
|
|
232
|
-
this.
|
|
233
|
-
|
|
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);
|
|
234
300
|
this.apiDefinitions.set(name, definition);
|
|
235
301
|
this.actionList.push({
|
|
236
302
|
match: (ctx) => ctx.apiName === name ? {} : false,
|
|
237
|
-
actionFn: (ctx
|
|
303
|
+
actionFn: (ctx) => this.runApi(ctx, definition, handler),
|
|
238
304
|
});
|
|
239
|
-
return this;
|
|
240
305
|
}
|
|
241
306
|
addHook(hookEvent, hookFn, priority = 0) {
|
|
242
307
|
if (hookEvent === "created") {
|
|
243
|
-
// Runs once, lazily,
|
|
308
|
+
// Runs once, lazily, before the first request or event is handled.
|
|
244
309
|
this.createdHooks.push(hookFn);
|
|
245
310
|
}
|
|
246
311
|
else {
|
|
@@ -272,11 +337,10 @@ export default class Lambder {
|
|
|
272
337
|
// The policy generics are `any` in the plugin signature on purpose: a
|
|
273
338
|
// module may annotate its parameter as the bare Lambder<SessionData> or
|
|
274
339
|
// as the app's narrowed alias, and both must chain. Registration-time
|
|
275
|
-
// assertions still
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
// 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.
|
|
280
344
|
use(plugin) {
|
|
281
345
|
return plugin(this);
|
|
282
346
|
}
|
|
@@ -285,9 +349,11 @@ export default class Lambder {
|
|
|
285
349
|
// What a handler or an app asks the instance for.
|
|
286
350
|
// =====================================================================
|
|
287
351
|
/**
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
* 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.
|
|
291
357
|
*/
|
|
292
358
|
getSessionController(ctx) {
|
|
293
359
|
const context = ctx;
|
|
@@ -320,26 +386,24 @@ export default class Lambder {
|
|
|
320
386
|
/**
|
|
321
387
|
* Every registered endpoint's signature, keyed by its hashed name: the
|
|
322
388
|
* 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.
|
|
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.
|
|
329
394
|
*/
|
|
330
395
|
async apiSignatures() {
|
|
331
396
|
return Object.fromEntries((await this.apiSignatureEntries()).map(({ key, signature }) => [key, signature]));
|
|
332
397
|
}
|
|
333
398
|
/**
|
|
334
|
-
* The same signatures
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
* 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.
|
|
339
403
|
*
|
|
340
|
-
* A build-time view
|
|
341
|
-
*
|
|
342
|
-
*
|
|
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.
|
|
343
407
|
*/
|
|
344
408
|
async apiSignatureEntries() {
|
|
345
409
|
const entries = await Promise.all([...this.apiDefinitions.values()].map(async (definition) => ({
|
|
@@ -392,32 +456,35 @@ export default class Lambder {
|
|
|
392
456
|
})();
|
|
393
457
|
this.initPromise = pending;
|
|
394
458
|
// A `created` hook usually reaches something that can be briefly
|
|
395
|
-
// unavailable (a first DynamoDB read, a secret fetch).
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
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.
|
|
400
464
|
pending.catch(() => { if (this.initPromise === pending)
|
|
401
465
|
this.initPromise = null; });
|
|
402
466
|
}
|
|
403
467
|
return this.initPromise;
|
|
404
468
|
}
|
|
405
|
-
applyCors(
|
|
406
|
-
applyCorsHeaders(this.corsConfig,
|
|
469
|
+
applyCors(allowedOrigin, response, isPreflight) {
|
|
470
|
+
applyCorsHeaders(this.corsConfig, allowedOrigin, response, isPreflight);
|
|
407
471
|
}
|
|
408
472
|
/**
|
|
409
473
|
* The beforeRender hooks, in priority order: the replaced context to
|
|
410
474
|
* continue with, or the response one of them answered with.
|
|
411
475
|
*
|
|
412
|
-
* Its own method because
|
|
413
|
-
* match, it
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
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.
|
|
419
486
|
*/
|
|
420
|
-
async runBeforeRenderHooks(ctx, resolver) {
|
|
487
|
+
async runBeforeRenderHooks(ctx, resolver, onContextReplaced) {
|
|
421
488
|
let currentCtx = ctx;
|
|
422
489
|
for (const hook of this.hookList["beforeRender"]) {
|
|
423
490
|
const hookResult = await hook.hookFn(currentCtx, resolver);
|
|
@@ -425,14 +492,20 @@ export default class Lambder {
|
|
|
425
492
|
throw hookResult;
|
|
426
493
|
if (hookResult instanceof LambderResponse)
|
|
427
494
|
return hookResult;
|
|
428
|
-
|
|
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);
|
|
429
502
|
}
|
|
430
503
|
return currentCtx;
|
|
431
504
|
}
|
|
432
|
-
async handleNoMatchedAction(ctx, resolver) {
|
|
505
|
+
async handleNoMatchedAction(ctx, resolver, onContextReplaced) {
|
|
433
506
|
// Before the fallback hooks, and with the same power it has on a
|
|
434
507
|
// matched route: the fallback hooks are typed void and cannot answer.
|
|
435
|
-
const beforeRenderResult = await this.runBeforeRenderHooks(ctx, resolver);
|
|
508
|
+
const beforeRenderResult = await this.runBeforeRenderHooks(ctx, resolver, onContextReplaced);
|
|
436
509
|
if (beforeRenderResult instanceof LambderResponse)
|
|
437
510
|
return beforeRenderResult;
|
|
438
511
|
const currentCtx = beforeRenderResult;
|
|
@@ -443,7 +516,7 @@ export default class Lambder {
|
|
|
443
516
|
if (isAPI) {
|
|
444
517
|
if (this.apiFallbackHandler)
|
|
445
518
|
return await this.apiFallbackHandler(currentCtx, resolver);
|
|
446
|
-
return responseFromAnswer(currentCtx.api ? this.pipeline.answerUnknownApi(currentCtx
|
|
519
|
+
return responseFromAnswer(currentCtx.api ? this.pipeline.answerUnknownApi(currentCtx) : apiNotFoundAnswer(this.apiVersion, currentCtx.logList));
|
|
447
520
|
}
|
|
448
521
|
if (this.publicFilesHandler) {
|
|
449
522
|
const fileResponse = await this.publicFilesHandler.handle(currentCtx);
|
|
@@ -459,16 +532,16 @@ export default class Lambder {
|
|
|
459
532
|
}
|
|
460
533
|
/**
|
|
461
534
|
* 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
|
-
*
|
|
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.
|
|
467
540
|
*/
|
|
468
541
|
isCorsPreflight(ctx) {
|
|
469
542
|
return ctx.method === "OPTIONS" && !!this.corsConfig;
|
|
470
543
|
}
|
|
471
|
-
async resolveRequest(ctx, resolver) {
|
|
544
|
+
async resolveRequest(ctx, resolver, onContextReplaced) {
|
|
472
545
|
if (this.isCorsPreflight(ctx))
|
|
473
546
|
return new LambderResponse({ statusCode: 204, body: null });
|
|
474
547
|
if (ctx.api) {
|
|
@@ -496,44 +569,51 @@ export default class Lambder {
|
|
|
496
569
|
}
|
|
497
570
|
}
|
|
498
571
|
if (!matched)
|
|
499
|
-
return await this.handleNoMatchedAction(ctx, resolver);
|
|
572
|
+
return await this.handleNoMatchedAction(ctx, resolver, onContextReplaced);
|
|
500
573
|
// Set before the hooks run, so a beforeRender hook on a matched route
|
|
501
574
|
// sees the route's own path params.
|
|
502
575
|
ctx.pathParams = matched.params;
|
|
503
|
-
const beforeRenderResult = await this.runBeforeRenderHooks(ctx, resolver);
|
|
576
|
+
const beforeRenderResult = await this.runBeforeRenderHooks(ctx, resolver, onContextReplaced);
|
|
504
577
|
if (beforeRenderResult instanceof LambderResponse)
|
|
505
578
|
return beforeRenderResult;
|
|
506
579
|
return await matched.action.actionFn(beforeRenderResult, resolver);
|
|
507
580
|
}
|
|
508
581
|
async render(event, lambdaContext) {
|
|
509
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;
|
|
510
587
|
try {
|
|
511
588
|
await this.ensureInitialized();
|
|
512
|
-
|
|
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);
|
|
513
597
|
const resolver = this.getResolver(ctx);
|
|
514
598
|
let response;
|
|
515
599
|
try {
|
|
516
|
-
|
|
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; });
|
|
517
606
|
}
|
|
518
607
|
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
|
-
}
|
|
608
|
+
response = await this.answerThrown(err, ctx, resolver);
|
|
531
609
|
}
|
|
532
|
-
//
|
|
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
|
|
533
614
|
// afterRender hook can override or delete a header the handler
|
|
534
|
-
// wrote
|
|
535
|
-
//
|
|
536
|
-
// was the last to speak in.
|
|
615
|
+
// wrote. Applied afterwards, it would put the handler's value
|
|
616
|
+
// straight back over the hook's.
|
|
537
617
|
const responseIntoHooks = response;
|
|
538
618
|
const headersAppliedIntoHooks = ctx.responseHeaders.size;
|
|
539
619
|
ctx.responseHeaders.applyTo(response);
|
|
@@ -542,143 +622,183 @@ export default class Lambder {
|
|
|
542
622
|
const hookResponse = await hook.hookFn(ctx, resolver, response);
|
|
543
623
|
if (hookResponse instanceof Error)
|
|
544
624
|
throw hookResponse;
|
|
545
|
-
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();
|
|
546
630
|
}
|
|
547
631
|
}
|
|
548
632
|
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
|
-
}
|
|
633
|
+
response = (await this.answerThrown(err, ctx, resolver)).copy();
|
|
558
634
|
}
|
|
559
635
|
// Only what the hooks themselves wrote (res.setHeader inside a
|
|
560
|
-
// hook) is left to apply, which
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
//
|
|
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.
|
|
565
641
|
ctx.responseHeaders.applyTo(response, response === responseIntoHooks ? headersAppliedIntoHooks : 0);
|
|
566
|
-
this.applyCors(
|
|
642
|
+
this.applyCors(allowedOrigin, response, this.isCorsPreflight(ctx));
|
|
567
643
|
return await finalizeResponse(ctx, response, this.finalizeOptions, ctx.eventFormat);
|
|
568
644
|
}
|
|
569
645
|
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
|
-
|
|
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);
|
|
596
698
|
}
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
try {
|
|
603
|
-
return await finalizeResponse(ctx, handlerErr, this.finalizeOptions, eventFormat);
|
|
604
|
-
}
|
|
605
|
-
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);
|
|
606
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);
|
|
607
715
|
}
|
|
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
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;
|
|
626
739
|
}
|
|
627
740
|
/**
|
|
628
741
|
* Fetch the session for a session route or short-circuit it with the
|
|
629
|
-
*
|
|
630
|
-
*
|
|
631
|
-
*
|
|
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.
|
|
632
745
|
*/
|
|
633
746
|
async requireSession(ctx, resolver) {
|
|
634
747
|
const session = await this.getSessionController(ctx).fetchSessionIfExists();
|
|
635
|
-
if (!session)
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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;
|
|
640
771
|
}
|
|
641
772
|
}
|
|
642
|
-
/**
|
|
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
|
+
*/
|
|
643
778
|
async renderEvent(event, lambdaContext) {
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
+
}
|
|
648
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;
|
|
649
796
|
}
|
|
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
797
|
}
|
|
655
798
|
// =====================================================================
|
|
656
799
|
// The API path
|
|
657
800
|
// The steps only an API call takes, around the shared core.
|
|
658
801
|
// =====================================================================
|
|
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
802
|
/**
|
|
683
803
|
* The answer for a rejected input: the app's
|
|
684
804
|
* setApiInputValidationErrorHandler when set, otherwise the standard 422
|
|
@@ -699,12 +819,14 @@ export default class Lambder {
|
|
|
699
819
|
* via res.die.*) becomes the answer the pipeline stores and hands back.
|
|
700
820
|
* The context is the pipeline's context, so a session it fetched is on
|
|
701
821
|
* ctx.session and the validated payload is on ctx.apiPayload when the
|
|
702
|
-
* 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.
|
|
703
824
|
*/
|
|
704
|
-
async runApi(ctx,
|
|
825
|
+
async runApi(ctx, definition, handler) {
|
|
705
826
|
const request = ctx.api;
|
|
706
827
|
if (!request)
|
|
707
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 });
|
|
708
830
|
// What the handler produced, in both forms: the answer went to the
|
|
709
831
|
// pipeline, and the response is kept so it can carry on unchanged.
|
|
710
832
|
const handled = { output: null };
|
|
@@ -725,13 +847,13 @@ export default class Lambder {
|
|
|
725
847
|
handled.output = { response, answer: answerFromResponse(response) };
|
|
726
848
|
return handled.output.answer;
|
|
727
849
|
});
|
|
728
|
-
//
|
|
729
|
-
//
|
|
730
|
-
//
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
//
|
|
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.
|
|
735
857
|
if (handled.output?.answer === answer)
|
|
736
858
|
return handled.output.response;
|
|
737
859
|
return responseFromAnswer(answer);
|
|
@@ -743,11 +865,10 @@ export default class Lambder {
|
|
|
743
865
|
}
|
|
744
866
|
/**
|
|
745
867
|
* 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.
|
|
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.
|
|
751
872
|
*
|
|
752
873
|
* ```typescript
|
|
753
874
|
* // app.ts (imports no api modules, so modules can import the type back)
|
|
@@ -768,15 +889,14 @@ export default class Lambder {
|
|
|
768
889
|
* export const handler = lambder.getHandler();
|
|
769
890
|
* ```
|
|
770
891
|
*
|
|
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.
|
|
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.
|
|
778
897
|
*/
|
|
779
898
|
export const initLambder = () => ({
|
|
899
|
+
...policyBuildersFor(),
|
|
780
900
|
create(options) {
|
|
781
901
|
return new Lambder(options);
|
|
782
902
|
},
|