lambder 6.0.2 → 7.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2316 -0
- package/README.md +60 -33
- package/dist/api/LambderApiAnswer.d.ts +40 -0
- package/dist/api/LambderApiAnswer.js +19 -0
- package/dist/api/LambderApiCallContext.d.ts +38 -0
- package/dist/api/LambderApiCallContext.js +13 -0
- package/dist/api/LambderApiDefinition.d.ts +18 -0
- package/dist/api/LambderApiDefinition.js +1 -0
- package/dist/api/LambderApiEnvelope.d.ts +67 -0
- package/dist/api/LambderApiEnvelope.js +180 -0
- package/dist/api/LambderApiGuards.d.ts +302 -0
- package/dist/api/LambderApiGuards.js +134 -0
- package/dist/api/LambderApiIdempotency.d.ts +122 -0
- package/dist/api/LambderApiIdempotency.js +330 -0
- package/dist/api/LambderApiPipeline.d.ts +134 -0
- package/dist/api/LambderApiPipeline.js +221 -0
- package/dist/api/LambderApiPolicyEngine.d.ts +36 -0
- package/dist/api/LambderApiPolicyEngine.js +77 -0
- package/dist/api/LambderApiRateLimits.d.ts +206 -0
- package/dist/api/LambderApiRateLimits.js +239 -0
- package/dist/api/LambderApiRequest.d.ts +101 -0
- package/dist/api/LambderApiRequest.js +129 -0
- package/dist/api/LambderApiValidationRefusal.d.ts +32 -0
- package/dist/api/LambderApiValidationRefusal.js +40 -0
- package/dist/client/LambderCaller.d.ts +62 -55
- package/dist/client/LambderCaller.js +147 -90
- package/dist/client/lambderFetchTransport.d.ts +9 -0
- package/dist/client/lambderFetchTransport.js +71 -0
- package/dist/client.d.ts +20 -10
- package/dist/client.js +11 -5
- package/dist/core/Lambder.d.ts +117 -253
- package/dist/core/Lambder.js +374 -341
- package/dist/core/LambderContext.d.ts +54 -44
- package/dist/core/LambderContext.js +41 -110
- package/dist/core/LambderCreateOptions.d.ts +285 -0
- package/dist/core/LambderCreateOptions.js +44 -0
- package/dist/core/LambderFiles.d.ts +1 -45
- package/dist/core/LambderFiles.js +18 -38
- package/dist/core/LambderIndexHtml.d.ts +37 -0
- package/dist/core/LambderIndexHtml.js +87 -0
- package/dist/core/LambderPolicyBuilders.d.ts +17 -0
- package/dist/core/LambderPolicyBuilders.js +16 -0
- package/dist/core/LambderPublicFiles.d.ts +5 -2
- package/dist/core/LambderPublicFiles.js +7 -2
- package/dist/core/LambderResolver.d.ts +8 -6
- package/dist/core/LambderResponse.d.ts +29 -11
- package/dist/core/LambderResponse.js +96 -49
- package/dist/core/LambderResponseBuilder.d.ts +18 -14
- package/dist/core/LambderResponseBuilder.js +19 -25
- package/dist/core/LambderRouting.d.ts +18 -7
- package/dist/core/LambderRouting.js +17 -7
- package/dist/core/LambderTemplatingEngine.d.ts +0 -62
- package/dist/core/LambderTemplatingEngine.js +7 -3
- package/dist/index.d.ts +85 -32
- package/dist/index.js +44 -16
- package/dist/invoke/LambderInvokeCaller.d.ts +46 -139
- package/dist/invoke/LambderInvokeCaller.js +140 -335
- package/dist/invoke/LambderInvokeOutcome.d.ts +165 -0
- package/dist/invoke/LambderInvokeOutcome.js +129 -0
- package/dist/invoke/LambderLambdaEvent.d.ts +81 -0
- package/dist/invoke/LambderLambdaEvent.js +187 -0
- package/dist/invoke/lambderHandlerTransport.d.ts +36 -0
- package/dist/invoke/lambderHandlerTransport.js +89 -0
- package/dist/mock/LambderMockApp.d.ts +352 -0
- package/dist/mock/LambderMockApp.js +815 -0
- package/dist/mock/LambderMockBrowserCookies.d.ts +55 -0
- package/dist/mock/LambderMockBrowserCookies.js +76 -0
- package/dist/mock/LambderMockCallRecorder.d.ts +85 -0
- package/dist/mock/LambderMockCallRecorder.js +183 -0
- package/dist/mock/LambderMockCreateOptions.d.ts +161 -0
- package/dist/mock/LambderMockCreateOptions.js +9 -0
- package/dist/mock/LambderMockEntryRegistry.d.ts +52 -0
- package/dist/mock/LambderMockEntryRegistry.js +126 -0
- package/dist/mock/LambderMockFailureInjector.d.ts +60 -0
- package/dist/mock/LambderMockFailureInjector.js +138 -0
- package/dist/mock/LambderMockTypes.d.ts +421 -0
- package/dist/mock/LambderMockTypes.js +8 -0
- package/dist/mock/lambderMockConsoleLogger.d.ts +16 -0
- package/dist/mock/lambderMockConsoleLogger.js +35 -0
- package/dist/mock/lambderMockInvokeTransport.d.ts +50 -0
- package/dist/mock/lambderMockInvokeTransport.js +52 -0
- package/dist/mock/lambderMockMswHandler.d.ts +99 -0
- package/dist/mock/lambderMockMswHandler.js +126 -0
- package/dist/mock.d.ts +34 -0
- package/dist/mock.js +27 -0
- package/dist/session/LambderSessionController.d.ts +199 -30
- package/dist/session/LambderSessionController.js +396 -82
- package/dist/session/LambderSessionCrypto.d.ts +66 -0
- package/dist/session/LambderSessionCrypto.js +101 -0
- package/dist/session/LambderSessionManager.d.ts +118 -80
- package/dist/session/LambderSessionManager.js +212 -184
- package/dist/shared/LambderI18n.d.ts +6 -6
- package/dist/shared/LambderI18n.js +1 -1
- package/dist/shared/contracts/LambderFileSource.d.ts +33 -0
- package/dist/shared/contracts/LambderFileSource.js +19 -0
- package/dist/shared/contracts/LambderIdempotencyStore.d.ts +66 -0
- package/dist/shared/contracts/LambderIdempotencyStore.js +12 -0
- package/dist/shared/contracts/LambderRateLimiter.d.ts +71 -0
- package/dist/shared/contracts/LambderRateLimiter.js +24 -0
- package/dist/shared/contracts/LambderSessionStore.d.ts +72 -0
- package/dist/shared/contracts/LambderSessionStore.js +13 -0
- package/dist/shared/transport/LambderApiTransport.d.ts +139 -0
- package/dist/shared/transport/LambderApiTransport.js +65 -0
- package/dist/shared/transport/LambderCookieJar.d.ts +121 -0
- package/dist/shared/transport/LambderCookieJar.js +246 -0
- package/dist/shared/transport/lambderCookieJarTransport.d.ts +30 -0
- package/dist/shared/transport/lambderCookieJarTransport.js +60 -0
- package/dist/shared/util/LambderBase64.d.ts +10 -0
- package/dist/shared/util/LambderBase64.js +27 -0
- package/dist/shared/util/LambderCallAbort.d.ts +62 -0
- package/dist/shared/util/LambderCallAbort.js +80 -0
- package/dist/shared/util/LambderClientIp.d.ts +32 -0
- package/dist/shared/util/LambderClientIp.js +56 -0
- package/dist/shared/util/LambderExpiringMap.d.ts +119 -0
- package/dist/shared/util/LambderExpiringMap.js +217 -0
- package/dist/shared/util/LambderKeyFields.d.ts +32 -0
- package/dist/shared/util/LambderKeyFields.js +34 -0
- package/dist/shared/util/LambderNodeModules.d.ts +9 -0
- package/dist/shared/util/LambderNodeModules.js +39 -0
- package/dist/shared/util/LambderOptionChecks.d.ts +17 -0
- package/dist/shared/util/LambderOptionChecks.js +33 -0
- package/dist/shared/util/LambderResponseBrand.d.ts +20 -0
- package/dist/shared/util/LambderResponseBrand.js +18 -0
- package/dist/shared/util/LambderTextDigest.d.ts +17 -0
- package/dist/shared/util/LambderTextDigest.js +34 -0
- package/dist/shared/util/LambderTypeUtilities.d.ts +33 -0
- package/dist/shared/util/LambderTypeUtilities.js +8 -0
- package/dist/shared/wire/LambderAnswerHeaders.d.ts +60 -0
- package/dist/shared/wire/LambderAnswerHeaders.js +94 -0
- package/dist/shared/wire/LambderApiContract.d.ts +129 -0
- package/dist/shared/wire/LambderApiOptionValues.d.ts +39 -0
- package/dist/shared/wire/LambderApiOptionValues.js +11 -0
- package/dist/shared/wire/LambderApiOutcome.d.ts +128 -0
- package/dist/shared/{LambderApiOutcome.js → wire/LambderApiOutcome.js} +16 -9
- package/dist/shared/{LambderApiError.d.ts → wire/LambderApiRefusal.d.ts} +48 -26
- package/dist/shared/{LambderApiError.js → wire/LambderApiRefusal.js} +13 -11
- package/dist/shared/wire/LambderCallOptions.d.ts +171 -0
- package/dist/shared/wire/LambderCallOptions.js +17 -0
- package/dist/shared/{LambderCompressionCodec.d.ts → wire/LambderCompressionCodec.d.ts} +10 -6
- package/dist/shared/{LambderCompressionCodec.js → wire/LambderCompressionCodec.js} +67 -23
- package/dist/shared/{LambderCompressionOption.d.ts → wire/LambderCompressionOption.d.ts} +1 -1
- package/dist/shared/{LambderCompressionOption.js → wire/LambderCompressionOption.js} +3 -4
- package/dist/shared/{LambderCrashDetail.d.ts → wire/LambderCrashDetail.d.ts} +10 -0
- package/dist/shared/{LambderCrashDetail.js → wire/LambderCrashDetail.js} +30 -0
- package/dist/shared/wire/LambderHttpStatus.d.ts +12 -0
- package/dist/shared/wire/LambderHttpStatus.js +1 -0
- package/dist/shared/{LambderRequestPayload.d.ts → wire/LambderRequestPayload.d.ts} +25 -17
- package/dist/shared/{LambderRequestPayload.js → wire/LambderRequestPayload.js} +29 -52
- package/dist/shared/wire/LambderSessionCookieNames.d.ts +9 -0
- package/dist/shared/wire/LambderSessionCookieNames.js +9 -0
- package/dist/stores/LambderDdbCache.d.ts +12 -9
- package/dist/stores/LambderDdbCache.js +56 -47
- package/dist/stores/{LambderDdbIdempotency.d.ts → LambderDdbIdempotencyStore.d.ts} +41 -31
- package/dist/stores/LambderDdbIdempotencyStore.js +319 -0
- package/dist/stores/LambderDdbRateLimiter.d.ts +30 -49
- package/dist/stores/LambderDdbRateLimiter.js +47 -45
- package/dist/stores/LambderDdbSdk.d.ts +83 -6
- package/dist/stores/LambderDdbSdk.js +83 -2
- package/dist/stores/LambderDdbSessionStore.d.ts +65 -0
- package/dist/stores/LambderDdbSessionStore.js +161 -0
- package/dist/stores/LambderHttpFileSource.d.ts +1 -1
- package/dist/stores/LambderHttpFileSource.js +10 -1
- package/dist/stores/LambderLocalFileSource.d.ts +15 -0
- package/dist/stores/LambderLocalFileSource.js +28 -0
- package/dist/stores/LambderMemoryIdempotencyStore.d.ts +63 -0
- package/dist/stores/LambderMemoryIdempotencyStore.js +113 -0
- package/dist/stores/LambderMemoryRateLimiter.d.ts +34 -0
- package/dist/stores/LambderMemoryRateLimiter.js +64 -0
- package/dist/stores/LambderMemorySessionStore.d.ts +48 -0
- package/dist/stores/LambderMemorySessionStore.js +74 -0
- package/dist/stores/LambderS3FileSource.d.ts +1 -1
- package/dist/stores/LambderS3FileSource.js +1 -1
- package/package.json +21 -19
- package/dist/client/LambderMSW.d.ts +0 -69
- package/dist/client/LambderMSW.js +0 -121
- package/dist/policies/LambderApiGuards.d.ts +0 -256
- package/dist/policies/LambderApiGuards.js +0 -94
- package/dist/policies/LambderApiIdempotency.d.ts +0 -58
- package/dist/policies/LambderApiIdempotency.js +0 -219
- package/dist/policies/LambderApiPolicies.d.ts +0 -42
- package/dist/policies/LambderApiPolicies.js +0 -52
- package/dist/policies/LambderApiRateLimits.d.ts +0 -132
- package/dist/policies/LambderApiRateLimits.js +0 -119
- package/dist/shared/LambderApiContract.d.ts +0 -57
- package/dist/shared/LambderApiOutcome.d.ts +0 -69
- package/dist/shared/LambderCallOptions.d.ts +0 -71
- package/dist/shared/LambderCallOptions.js +0 -16
- package/dist/shared/node-polyfills.d.ts +0 -4
- package/dist/shared/node-polyfills.js +0 -58
- package/dist/stores/LambderDdbIdempotency.js +0 -229
- package/dist/testing.d.ts +0 -9
- package/dist/testing.js +0 -8
- /package/dist/shared/{LambderApiContract.js → wire/LambderApiContract.js} +0 -0
- /package/dist/{core → shared/wire}/LambderCookie.d.ts +0 -0
- /package/dist/{core → shared/wire}/LambderCookie.js +0 -0
package/dist/core/Lambder.js
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import LambderResolver from "./LambderResolver.js";
|
|
2
2
|
import LambderResponseBuilder from "./LambderResponseBuilder.js";
|
|
3
|
-
import { LambderResponse, finalizeResponse, DEFAULT_FINALIZE_OPTIONS, DEFAULT_RESPONSE_COMPRESSION_SETTINGS, } from "./LambderResponse.js";
|
|
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
5
|
import { applyCorsHeaders } from "./LambderCors.js";
|
|
6
6
|
import LambderSessionManager from "../session/LambderSessionManager.js";
|
|
7
|
-
import { resolveCompressionOption } from "../shared/LambderCompressionOption.js";
|
|
8
|
-
import LambderSessionController from "../session/LambderSessionController.js";
|
|
7
|
+
import { resolveCompressionOption } from "../shared/wire/LambderCompressionOption.js";
|
|
9
8
|
import { LambderPublicFilesHandler } from "./LambderPublicFiles.js";
|
|
9
|
+
import { LambderIndexHtmlHandler } from "./LambderIndexHtml.js";
|
|
10
10
|
import { LambderFiles } from "./LambderFiles.js";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
11
|
+
import { isLambderApiRefusal } from "../shared/wire/LambderApiRefusal.js";
|
|
12
|
+
import { LambderApiPipeline } from "../api/LambderApiPipeline.js";
|
|
13
|
+
import { apiNotFoundAnswer, crashAnswer, refusalAnswer, } from "../api/LambderApiEnvelope.js";
|
|
14
|
+
import { createContext, isV2HttpEvent } from "./LambderContext.js";
|
|
15
|
+
import { COMPRESSED_PAYLOAD_GZ_FIELD, COMPRESSED_PAYLOAD_BR_FIELD, COMPRESSED_PAYLOAD_BYTES_FIELD } from "../shared/wire/LambderRequestPayload.js";
|
|
16
|
+
import { coerceToError } from "../shared/wire/LambderCrashDetail.js";
|
|
17
|
+
import { assertCreateOptions, } from "./LambderCreateOptions.js";
|
|
15
18
|
/**
|
|
16
19
|
* Main Lambder class for building type-safe serverless APIs. Create
|
|
17
20
|
* instances with initLambder<SessionData>().create({...}) (see below): the
|
|
@@ -25,6 +28,7 @@ import { DEFAULT_MAX_RESTORED_PAYLOAD_BYTES } from "../shared/LambderRequestPayl
|
|
|
25
28
|
* @typeParam _TIdempotencyEnabled - @internal True when create() received idempotency (do not pass manually)
|
|
26
29
|
* @typeParam _TSessionGuardsRequired - @internal True when create() received requireSessionApiGuards (do not pass manually)
|
|
27
30
|
* @typeParam _TPublicGuardsRequired - @internal True when create() received requirePublicApiGuards (do not pass manually)
|
|
31
|
+
* @typeParam _TSessionsEnabled - @internal True when create() received the session option (do not pass manually). It defaults to TRUE, unlike its siblings: a plugin module annotates its parameter as the bare Lambder<SessionData>, and that annotation has to keep registering session APIs. create() is where the option is actually known, so create() is where the false comes from; `new Lambder(...)` keeps only the registration-time throw.
|
|
28
32
|
*
|
|
29
33
|
* @example
|
|
30
34
|
* ```typescript
|
|
@@ -36,6 +40,10 @@ import { DEFAULT_MAX_RESTORED_PAYLOAD_BYTES } from "../shared/LambderRequestPayl
|
|
|
36
40
|
* ```
|
|
37
41
|
*/
|
|
38
42
|
export default class Lambder {
|
|
43
|
+
// =====================================================================
|
|
44
|
+
// Construction
|
|
45
|
+
// Everything an instance is, fixed before the first registration.
|
|
46
|
+
// =====================================================================
|
|
39
47
|
apiPath;
|
|
40
48
|
apiVersion;
|
|
41
49
|
/** The instance's file reader (source + caches), or null without the files option. */
|
|
@@ -52,7 +60,8 @@ export default class Lambder {
|
|
|
52
60
|
*/
|
|
53
61
|
ApiContract;
|
|
54
62
|
actionList = [];
|
|
55
|
-
|
|
63
|
+
/** The API core: the pipeline every API call runs through, shared in shape with the mock runtime. */
|
|
64
|
+
pipeline;
|
|
56
65
|
registeredApiNames = new Set();
|
|
57
66
|
hookList = { "beforeRender": [], "afterRender": [], "fallback": [] };
|
|
58
67
|
createdHooks = [];
|
|
@@ -63,18 +72,15 @@ export default class Lambder {
|
|
|
63
72
|
apiInputValidationErrorHandler = null;
|
|
64
73
|
sessionExpiredRouteHandler = null;
|
|
65
74
|
publicFilesHandler = null;
|
|
66
|
-
|
|
75
|
+
indexHtmlHandler = null;
|
|
67
76
|
eventActionList = [];
|
|
68
77
|
corsConfig = null;
|
|
69
78
|
finalizeOptions;
|
|
70
|
-
maxRequestPayloadBytes;
|
|
71
79
|
requireSessionApiGuards;
|
|
80
|
+
trustedClientIpHeaders;
|
|
72
81
|
requirePublicApiGuards;
|
|
73
|
-
lambderSessionManager;
|
|
74
|
-
sessionCookieOptions = {};
|
|
75
|
-
sessionTokenCookieKey = "LMDRSESSIONTKID";
|
|
76
|
-
sessionCsrfCookieKey = "LMDRSESSIONCSTK";
|
|
77
82
|
constructor(options = {}) {
|
|
83
|
+
assertCreateOptions(options);
|
|
78
84
|
this.files = options.files ? new LambderFiles(options.files) : null;
|
|
79
85
|
this.apiPath = options.apiPath ?? "/api";
|
|
80
86
|
this.apiVersion = options.apiVersion ?? null;
|
|
@@ -85,45 +91,43 @@ export default class Lambder {
|
|
|
85
91
|
etag: options.etag ?? DEFAULT_FINALIZE_OPTIONS.etag,
|
|
86
92
|
maxResponseBytes: options.maxResponseBytes ?? DEFAULT_FINALIZE_OPTIONS.maxResponseBytes,
|
|
87
93
|
};
|
|
88
|
-
this.maxRequestPayloadBytes = options.maxRequestPayloadBytes ?? DEFAULT_MAX_RESTORED_PAYLOAD_BYTES;
|
|
89
|
-
if (!Number.isSafeInteger(this.maxRequestPayloadBytes) || this.maxRequestPayloadBytes <= 0) {
|
|
90
|
-
throw new Error("maxRequestPayloadBytes must be a positive integer");
|
|
91
|
-
}
|
|
92
94
|
if (options.cors !== undefined && options.cors !== false) {
|
|
93
95
|
this.corsConfig = options.cors === true ? {} : options.cors;
|
|
94
96
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
this.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
97
|
+
const session = options.session;
|
|
98
|
+
this.pipeline = new LambderApiPipeline({
|
|
99
|
+
apiVersion: this.apiVersion,
|
|
100
|
+
maxRequestPayloadBytes: options.maxRequestPayloadBytes,
|
|
101
|
+
// The app's own validation handler is read at call time, since
|
|
102
|
+
// setApiInputValidationErrorHandler runs after creation.
|
|
103
|
+
onInvalidInput: (zodError, ctx) => this.inputValidationRefusal(ctx, zodError),
|
|
104
|
+
sessions: session
|
|
105
|
+
? {
|
|
106
|
+
manager: new LambderSessionManager({
|
|
107
|
+
store: session.store,
|
|
108
|
+
sessionSalt: session.sessionSalt,
|
|
109
|
+
enableSlidingExpiration: session.enableSlidingExpiration,
|
|
110
|
+
slidingWriteIntervalSeconds: session.slidingWriteIntervalSeconds,
|
|
111
|
+
dataRefresh: session.dataRefresh,
|
|
112
|
+
crypto: session.crypto,
|
|
113
|
+
}),
|
|
114
|
+
tokenCookieKey: session.tokenCookieKey,
|
|
115
|
+
csrfCookieKey: session.csrfCookieKey,
|
|
116
|
+
cookieOptions: session.cookie,
|
|
117
|
+
}
|
|
118
|
+
: undefined,
|
|
119
|
+
rateLimits: options.rateLimits,
|
|
120
|
+
guards: options.guards,
|
|
121
|
+
idempotency: options.idempotency,
|
|
122
|
+
});
|
|
123
|
+
this.trustedClientIpHeaders = options.trustedClientIpHeaders ?? [];
|
|
120
124
|
this.requireSessionApiGuards = options.requireSessionApiGuards ?? false;
|
|
121
125
|
this.requirePublicApiGuards = options.requirePublicApiGuards ?? false;
|
|
122
|
-
const requireFlag = this.requireSessionApiGuards ? "requireSessionApiGuards" : "requirePublicApiGuards";
|
|
123
|
-
if ((this.requireSessionApiGuards || this.requirePublicApiGuards) && !options.guards) {
|
|
124
|
-
throw new Error(`Lambder: ${requireFlag} needs a guards map at creation for APIs to declare from.`);
|
|
125
|
-
}
|
|
126
126
|
}
|
|
127
|
+
// =====================================================================
|
|
128
|
+
// Registration
|
|
129
|
+
// What an app declares on the instance; all of it chains.
|
|
130
|
+
// =====================================================================
|
|
127
131
|
setRouteFallbackHandler(routeFallbackHandler) {
|
|
128
132
|
this.routeFallbackHandler = routeFallbackHandler;
|
|
129
133
|
return this;
|
|
@@ -148,15 +152,16 @@ export default class Lambder {
|
|
|
148
152
|
/**
|
|
149
153
|
* Terminal public-file layer. Runs only when no route matched, so it can
|
|
150
154
|
* never shadow routes registered after it. Serves files from the `files`
|
|
151
|
-
* source configured at creation,
|
|
155
|
+
* source configured at creation, under the reader's path rule, mime-typed,
|
|
152
156
|
* memory-cached, with the immutable-cache heuristic for content-hashed
|
|
153
|
-
* assets
|
|
154
|
-
*
|
|
155
|
-
*
|
|
157
|
+
* assets. Only configured methods reach it, default GET/HEAD, as for
|
|
158
|
+
* serveIndexHtml; a gated-out method and a path the source has no file
|
|
159
|
+
* for both fall through to setRouteFallbackHandler, where the app decides
|
|
160
|
+
* what remains (e.g. render an app shell with res.templateFile).
|
|
156
161
|
*/
|
|
157
162
|
servePublicFiles(options = {}) {
|
|
158
163
|
if (!this.files)
|
|
159
|
-
throw new Error("servePublicFiles requires the files option at creation (e.g. files: new LambderLocalFileSource({ root }))");
|
|
164
|
+
throw new Error("Lambder: servePublicFiles requires the files option at creation (e.g. files: new LambderLocalFileSource({ root }))");
|
|
160
165
|
this.publicFilesHandler = new LambderPublicFilesHandler(this.files, options);
|
|
161
166
|
return this;
|
|
162
167
|
}
|
|
@@ -170,78 +175,9 @@ export default class Lambder {
|
|
|
170
175
|
* res.templateFile (markers optional) with no-cache.
|
|
171
176
|
*/
|
|
172
177
|
serveIndexHtml(handler, options = {}) {
|
|
173
|
-
this.
|
|
178
|
+
this.indexHtmlHandler = new LambderIndexHtmlHandler(handler ?? null, options);
|
|
174
179
|
return this;
|
|
175
180
|
}
|
|
176
|
-
/** Apply the serveIndexHtml gates; null means fall through. */
|
|
177
|
-
async tryServeIndexHtml(ctx, resolver) {
|
|
178
|
-
if (!this.indexHtmlConfig)
|
|
179
|
-
return null;
|
|
180
|
-
const { handler, options } = this.indexHtmlConfig;
|
|
181
|
-
const methods = (options.methods ?? ["GET", "HEAD"]).map((m) => m.toUpperCase());
|
|
182
|
-
if (!methods.includes(ctx.method.toUpperCase()))
|
|
183
|
-
return null;
|
|
184
|
-
if ((options.skipFilePaths ?? false) && (ctx.path.split("/").pop() ?? "").includes("."))
|
|
185
|
-
return null;
|
|
186
|
-
if (options.redirectTrailingSlash && ctx.path.length > 1 && ctx.path.endsWith("/")) {
|
|
187
|
-
const target = ctx.path.replace(/\/+$/, "") || "/";
|
|
188
|
-
return resolver.redirect(target + buildQueryString(ctx), 301);
|
|
189
|
-
}
|
|
190
|
-
const response = handler
|
|
191
|
-
? await handler(ctx, resolver)
|
|
192
|
-
: await resolver.templateFile(typeof options.indexFile === "function" ? options.indexFile(ctx) : (options.indexFile ?? "index.html"), {}, { cacheControl: "no-cache" });
|
|
193
|
-
if (options.compress !== undefined) {
|
|
194
|
-
response.compress = typeof options.compress === "function" ? options.compress(ctx) : options.compress;
|
|
195
|
-
}
|
|
196
|
-
return response;
|
|
197
|
-
}
|
|
198
|
-
getOrCreatePolicyEngine() {
|
|
199
|
-
// Late-bound: the handler may be set after creation, so the engine
|
|
200
|
-
// asks at request time rather than capturing it here.
|
|
201
|
-
if (!this.apiPolicyEngine)
|
|
202
|
-
this.apiPolicyEngine = new LambderApiPolicyEngine((ctx, resolver, zodError) => this.inputValidationRefusal(ctx, resolver, zodError));
|
|
203
|
-
return this.apiPolicyEngine;
|
|
204
|
-
}
|
|
205
|
-
/**
|
|
206
|
-
* The response for a rejected input: the app's
|
|
207
|
-
* setApiInputValidationErrorHandler when set, otherwise the standard 422
|
|
208
|
-
* body. The API's own schema and every preflight slice (guard inputs,
|
|
209
|
-
* rate-limit keys) answer through here, so one failure has one shape.
|
|
210
|
-
*/
|
|
211
|
-
async inputValidationRefusal(ctx, resolver, zodError) {
|
|
212
|
-
if (this.apiInputValidationErrorHandler)
|
|
213
|
-
return await this.apiInputValidationErrorHandler(ctx, resolver, zodError);
|
|
214
|
-
// Spelled out rather than serialized as-is: zod 4 keeps `issues` as a
|
|
215
|
-
// non-enumerable property, so JSON.stringify(zodError) would carry the
|
|
216
|
-
// issues only inside the message string, and a client's validation
|
|
217
|
-
// handler would receive a ZodError with nothing to branch on.
|
|
218
|
-
return resolver.json({
|
|
219
|
-
error: "Input validation failed",
|
|
220
|
-
zodError: { name: zodError.name, message: zodError.message, issues: zodError.issues },
|
|
221
|
-
}, { statusCode: 422 });
|
|
222
|
-
}
|
|
223
|
-
/** Registration-time checks shared by addApi/addSessionApi. */
|
|
224
|
-
assertApiRegistration(name, mode, options) {
|
|
225
|
-
if (this.registeredApiNames.has(name)) {
|
|
226
|
-
throw new Error(`Lambder: duplicate API name "${name}". Dispatch is first-match, so the second registration would be silently dead code.`);
|
|
227
|
-
}
|
|
228
|
-
this.registeredApiNames.add(name);
|
|
229
|
-
const guardsRequired = mode === "session" ? this.requireSessionApiGuards : this.requirePublicApiGuards;
|
|
230
|
-
if (guardsRequired && options.guards === undefined) {
|
|
231
|
-
const optOut = mode === "session"
|
|
232
|
-
? "the named no-op guard that marks the session itself as the whole authorization"
|
|
233
|
-
: "the named no-op guard that records why anyone may call it";
|
|
234
|
-
throw new Error(`Lambder: ${mode} API "${name}" declares no guards, and require${mode === "session" ? "Session" : "Public"}ApiGuards is on. ` +
|
|
235
|
-
`Declare the guard that authorizes it, or ${optOut}.`);
|
|
236
|
-
}
|
|
237
|
-
const usesPolicies = options.rateLimit !== undefined || options.guards !== undefined || options.idempotency !== undefined;
|
|
238
|
-
if (!usesPolicies)
|
|
239
|
-
return;
|
|
240
|
-
if (!this.apiPolicyEngine) {
|
|
241
|
-
throw new Error(`Lambder: API "${name}" declares rateLimit/guards/idempotency, but none of rateLimits/guards/idempotency was configured at creation.`);
|
|
242
|
-
}
|
|
243
|
-
this.apiPolicyEngine.assertRegistration(name, mode, options);
|
|
244
|
-
}
|
|
245
181
|
addRoute(condition, actionFn) {
|
|
246
182
|
this.actionList.push({
|
|
247
183
|
match: compileRouteMatcher(condition),
|
|
@@ -254,96 +190,33 @@ export default class Lambder {
|
|
|
254
190
|
match: compileRouteMatcher(condition),
|
|
255
191
|
actionFn: async (ctx, resolver) => {
|
|
256
192
|
await this.requireSession(ctx, resolver);
|
|
193
|
+
// requireSession answered already if there was no session, so
|
|
194
|
+
// the only narrowing left is null to non-null.
|
|
257
195
|
return await actionFn(ctx, resolver);
|
|
258
196
|
},
|
|
259
197
|
});
|
|
260
198
|
return this;
|
|
261
199
|
}
|
|
262
|
-
// Plugin system
|
|
263
|
-
// The policy generics are `any` in the plugin signature on purpose: a
|
|
264
|
-
// module may annotate its parameter as the bare Lambder<SessionData> or
|
|
265
|
-
// as the app's narrowed alias, and both must chain. Registration-time
|
|
266
|
-
// assertions still verify every referenced policy/guard name at runtime.
|
|
267
|
-
// Every policy generic must be listed here: one short of the class's
|
|
268
|
-
// parameter list and the missing one silently falls back to its default,
|
|
269
|
-
// which makes an instance carrying the non-default value unassignable to
|
|
270
|
-
// its own plugins (requireSessionApiGuards did exactly that in 4.7.1).
|
|
271
|
-
use(plugin) {
|
|
272
|
-
return plugin(this);
|
|
273
|
-
}
|
|
274
200
|
// Typed API with Zod
|
|
275
201
|
addApi(name, schema, handler) {
|
|
276
202
|
this.assertApiRegistration(name, "public", schema);
|
|
203
|
+
const definition = { name, mode: "public", guards: schema.guards, rateLimit: schema.rateLimit, idempotency: schema.idempotency, input: schema.input };
|
|
277
204
|
this.actionList.push({
|
|
278
205
|
match: (ctx) => ctx.apiName === name ? {} : false,
|
|
279
|
-
actionFn:
|
|
280
|
-
// Replay fast path first: a completed idempotent request must
|
|
281
|
-
// answer its stored response without burning rate-limit quota
|
|
282
|
-
// or re-running guards (no handler executes either way).
|
|
283
|
-
if (this.apiPolicyEngine && schema.idempotency) {
|
|
284
|
-
const replay = await this.apiPolicyEngine.findReplay(name, ctx);
|
|
285
|
-
if (replay)
|
|
286
|
-
return replay;
|
|
287
|
-
}
|
|
288
|
-
if (this.apiPolicyEngine)
|
|
289
|
-
await this.apiPolicyEngine.runPreflight(name, ctx, resolver, schema);
|
|
290
|
-
const inputResult = schema.input.safeParse(ctx.apiPayload);
|
|
291
|
-
if (!inputResult.success)
|
|
292
|
-
return await this.inputValidationRefusal(ctx, resolver, inputResult.error);
|
|
293
|
-
ctx.apiPayload = inputResult.data;
|
|
294
|
-
const run = async () => await handler(ctx, resolver);
|
|
295
|
-
if (this.apiPolicyEngine && schema.idempotency)
|
|
296
|
-
return await this.apiPolicyEngine.withIdempotency(name, ctx, schema.idempotency, run);
|
|
297
|
-
return await run();
|
|
298
|
-
},
|
|
206
|
+
actionFn: (ctx, resolver) => this.runApi(ctx, resolver, definition, handler),
|
|
299
207
|
});
|
|
300
208
|
return this;
|
|
301
209
|
}
|
|
302
210
|
// Typed Session API with Zod
|
|
303
211
|
addSessionApi(name, schema, handler) {
|
|
304
212
|
this.assertApiRegistration(name, "session", schema);
|
|
213
|
+
const definition = { name, mode: "session", guards: schema.guards, rateLimit: schema.rateLimit, idempotency: schema.idempotency, input: schema.input };
|
|
305
214
|
this.actionList.push({
|
|
306
215
|
match: (ctx) => ctx.apiName === name ? {} : false,
|
|
307
|
-
actionFn:
|
|
308
|
-
await this.requireSession(ctx, resolver);
|
|
309
|
-
// Replay fast path (after the session fetch: the replay scope
|
|
310
|
-
// is keyed per session): see addApi.
|
|
311
|
-
if (this.apiPolicyEngine && schema.idempotency) {
|
|
312
|
-
const replay = await this.apiPolicyEngine.findReplay(name, ctx);
|
|
313
|
-
if (replay)
|
|
314
|
-
return replay;
|
|
315
|
-
}
|
|
316
|
-
if (this.apiPolicyEngine)
|
|
317
|
-
await this.apiPolicyEngine.runPreflight(name, ctx, resolver, schema);
|
|
318
|
-
const inputResult = schema.input.safeParse(ctx.apiPayload);
|
|
319
|
-
if (!inputResult.success)
|
|
320
|
-
return await this.inputValidationRefusal(ctx, resolver, inputResult.error);
|
|
321
|
-
ctx.apiPayload = inputResult.data;
|
|
322
|
-
const run = async () => await handler(ctx, resolver);
|
|
323
|
-
if (this.apiPolicyEngine && schema.idempotency)
|
|
324
|
-
return await this.apiPolicyEngine.withIdempotency(name, ctx, schema.idempotency, run);
|
|
325
|
-
return await run();
|
|
326
|
-
}
|
|
216
|
+
actionFn: (ctx, resolver) => this.runApi(ctx, resolver, definition, handler),
|
|
327
217
|
});
|
|
328
218
|
return this;
|
|
329
219
|
}
|
|
330
|
-
/**
|
|
331
|
-
* Fetch the session or short-circuit the request: API calls get the
|
|
332
|
-
* protocol's { sessionExpired: true } response (handled by LambderCaller),
|
|
333
|
-
* routes get the sessionExpiredRouteHandler response (default 401).
|
|
334
|
-
*/
|
|
335
|
-
async requireSession(ctx, resolver) {
|
|
336
|
-
const session = await this.getSessionController(ctx).fetchSessionIfExists();
|
|
337
|
-
if (!session) {
|
|
338
|
-
if (ctx._otherInternal.isApiCall) {
|
|
339
|
-
throw resolver.api(null, { sessionExpired: true });
|
|
340
|
-
}
|
|
341
|
-
if (this.sessionExpiredRouteHandler) {
|
|
342
|
-
throw await this.sessionExpiredRouteHandler(ctx, resolver);
|
|
343
|
-
}
|
|
344
|
-
throw resolver.status(401, "Session required.");
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
220
|
addHook(hookEvent, hookFn, priority = 0) {
|
|
348
221
|
if (hookEvent === "created") {
|
|
349
222
|
// Runs once, lazily, at the first render() call.
|
|
@@ -355,16 +228,53 @@ export default class Lambder {
|
|
|
355
228
|
}
|
|
356
229
|
return this;
|
|
357
230
|
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
231
|
+
addAction(filter, actionFn) {
|
|
232
|
+
// HTTP side: joins the route/API chain in registration order.
|
|
233
|
+
this.actionList.push({
|
|
234
|
+
match: (ctx) => filter(ctx.event, ctx) ? {} : false,
|
|
235
|
+
actionFn: async (ctx, resolver) => {
|
|
236
|
+
const result = await actionFn(ctx.event, { ctx, res: resolver, lambdaContext: ctx.lambdaContext });
|
|
237
|
+
if (!(result instanceof LambderResponse)) {
|
|
238
|
+
throw new Error("Lambder: an addAction matched an HTTP request but did not return a response. Build one with tools.res.");
|
|
239
|
+
}
|
|
240
|
+
return result;
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
// Non-HTTP side.
|
|
244
|
+
this.eventActionList.push({
|
|
245
|
+
match: (event) => filter(event, null),
|
|
246
|
+
actionFn: (event, lambdaContext) => actionFn(event, { ctx: null, res: null, lambdaContext }),
|
|
367
247
|
});
|
|
248
|
+
return this;
|
|
249
|
+
}
|
|
250
|
+
// Plugin system
|
|
251
|
+
// The policy generics are `any` in the plugin signature on purpose: a
|
|
252
|
+
// module may annotate its parameter as the bare Lambder<SessionData> or
|
|
253
|
+
// as the app's narrowed alias, and both must chain. Registration-time
|
|
254
|
+
// assertions still verify every referenced policy/guard name at runtime.
|
|
255
|
+
// Every policy generic must be listed here: one short of the class's
|
|
256
|
+
// parameter list and the missing one silently falls back to its default,
|
|
257
|
+
// which makes an instance carrying the non-default value unassignable to
|
|
258
|
+
// its own plugins.
|
|
259
|
+
use(plugin) {
|
|
260
|
+
return plugin(this);
|
|
261
|
+
}
|
|
262
|
+
// =====================================================================
|
|
263
|
+
// Accessors
|
|
264
|
+
// What a handler or an app asks the instance for.
|
|
265
|
+
// =====================================================================
|
|
266
|
+
/**
|
|
267
|
+
* Sessions for this request: what handlers create, rotate, refresh and
|
|
268
|
+
* end sessions with. An API call presents its posted CSRF token; a route
|
|
269
|
+
* presents cookies alone.
|
|
270
|
+
*/
|
|
271
|
+
getSessionController(ctx) {
|
|
272
|
+
const context = ctx;
|
|
273
|
+
return this.pipeline.sessionController(context, context.api ? LambderApiPipeline.sessionInfoOf(context.api) : { host: context.host, cookies: context.cookieList, csrfToken: null });
|
|
274
|
+
}
|
|
275
|
+
/** The session manager, for code that works on sessions outside a request (maintenance, tests). */
|
|
276
|
+
getSessionManager() {
|
|
277
|
+
return this.pipeline.sessionManager;
|
|
368
278
|
}
|
|
369
279
|
getResponseBuilder(ctx) {
|
|
370
280
|
return new LambderResponseBuilder({
|
|
@@ -382,25 +292,11 @@ export default class Lambder {
|
|
|
382
292
|
});
|
|
383
293
|
}
|
|
384
294
|
;
|
|
385
|
-
/** Map a thrown LambderApiError onto the structured API envelope. */
|
|
386
|
-
apiErrorResponse(err, resolver) {
|
|
387
|
-
return resolver.api(null, {
|
|
388
|
-
...(err.errorMessage !== undefined ? { errorMessage: err.errorMessage } : {}),
|
|
389
|
-
...(err.notAuthorized ? { notAuthorized: true } : {}),
|
|
390
|
-
...(err.sessionExpired ? { sessionExpired: true } : {}),
|
|
391
|
-
}, {
|
|
392
|
-
...(err.statusCode !== undefined ? { statusCode: err.statusCode } : {}),
|
|
393
|
-
...(err.headers ? { headers: err.headers } : {}),
|
|
394
|
-
});
|
|
395
|
-
}
|
|
396
295
|
getHandler() {
|
|
397
296
|
return ((event, context) => Lambder.isHttpEvent(event)
|
|
398
297
|
? this.render(event, context)
|
|
399
298
|
: this.renderEvent(event, context));
|
|
400
299
|
}
|
|
401
|
-
// ---------------------------------------------------------------------
|
|
402
|
-
// Actions (raw-event or context filtering; the only handler for non-HTTP)
|
|
403
|
-
// ---------------------------------------------------------------------
|
|
404
300
|
/** True when the Lambda event is an API Gateway HTTP event (REST API v1 or HTTP API / Function URL v2). */
|
|
405
301
|
static isHttpEvent(event) {
|
|
406
302
|
if (!event || typeof event !== "object")
|
|
@@ -409,99 +305,113 @@ export default class Lambder {
|
|
|
409
305
|
return true;
|
|
410
306
|
return isV2HttpEvent(event);
|
|
411
307
|
}
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
actionFn: async (ctx, resolver) => {
|
|
417
|
-
const result = await actionFn(ctx.event, { ctx, res: resolver, lambdaContext: ctx.lambdaContext });
|
|
418
|
-
if (!(result instanceof LambderResponse)) {
|
|
419
|
-
throw new Error("Lambder: an addAction matched an HTTP request but did not return a response. Build one with tools.res.");
|
|
420
|
-
}
|
|
421
|
-
return result;
|
|
422
|
-
},
|
|
423
|
-
});
|
|
424
|
-
// Non-HTTP side.
|
|
425
|
-
this.eventActionList.push({
|
|
426
|
-
match: (event) => filter(event, null),
|
|
427
|
-
actionFn: (event, lambdaContext) => actionFn(event, { ctx: null, res: null, lambdaContext }),
|
|
428
|
-
});
|
|
429
|
-
return this;
|
|
430
|
-
}
|
|
431
|
-
/** Dispatch a non-HTTP Lambda event to the registered actions. */
|
|
432
|
-
async renderEvent(event, lambdaContext) {
|
|
433
|
-
await this.ensureInitialized();
|
|
434
|
-
for (const action of this.eventActionList) {
|
|
435
|
-
if (action.match(event)) {
|
|
436
|
-
return await action.actionFn(event, lambdaContext);
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
const summary = event && typeof event === "object"
|
|
440
|
-
? ` (source: ${String(event.source ?? "?")}, detail-type: ${String(event["detail-type"] ?? "?")})`
|
|
441
|
-
: "";
|
|
442
|
-
throw new Error(`Lambder: no action matched non-HTTP event${summary}. Register one with addAction(); a trailing addAction(() => true, ...) acts as a fallback.`);
|
|
443
|
-
}
|
|
444
|
-
// ---------------------------------------------------------------------
|
|
445
|
-
// Render pipeline
|
|
446
|
-
// ---------------------------------------------------------------------
|
|
308
|
+
// =====================================================================
|
|
309
|
+
// The request path
|
|
310
|
+
// One HTTP invocation, from the event to the finalized response.
|
|
311
|
+
// =====================================================================
|
|
447
312
|
ensureInitialized() {
|
|
448
313
|
if (!this.initPromise) {
|
|
449
|
-
|
|
314
|
+
const pending = (async () => {
|
|
450
315
|
for (const hookFn of this.createdHooks) {
|
|
451
316
|
await hookFn(this);
|
|
452
317
|
}
|
|
453
318
|
})();
|
|
319
|
+
this.initPromise = pending;
|
|
320
|
+
// A `created` hook usually reaches something that can be briefly
|
|
321
|
+
// unavailable (a first DynamoDB read, a secret fetch). Keeping the
|
|
322
|
+
// rejected promise meant the warm container answered every later
|
|
323
|
+
// invocation with the first failure and never recovered, so the
|
|
324
|
+
// failure is forgotten and the next invocation runs the hooks
|
|
325
|
+
// again. Whoever is awaiting this one still gets the rejection.
|
|
326
|
+
pending.catch(() => { if (this.initPromise === pending)
|
|
327
|
+
this.initPromise = null; });
|
|
454
328
|
}
|
|
455
329
|
return this.initPromise;
|
|
456
330
|
}
|
|
457
331
|
applyCors(ctx, response, isPreflight) {
|
|
458
332
|
applyCorsHeaders(this.corsConfig, ctx, response, isPreflight);
|
|
459
333
|
}
|
|
334
|
+
/**
|
|
335
|
+
* The beforeRender hooks, in priority order: the replaced context to
|
|
336
|
+
* continue with, or the response one of them answered with.
|
|
337
|
+
*
|
|
338
|
+
* Its own method because BOTH request paths run it. Left inline after the
|
|
339
|
+
* match, it ran for routes and APIs and for nothing else, so a
|
|
340
|
+
* servePublicFiles or serveIndexHtml answer, which is every asset and
|
|
341
|
+
* every app-shell page, skipped the one hook that can inspect a request,
|
|
342
|
+
* replace its context or short-circuit it: a security header written in a
|
|
343
|
+
* hook reached the API answers and not the HTML it was written for, and a
|
|
344
|
+
* maintenance-mode hook served the whole frontend anyway.
|
|
345
|
+
*/
|
|
346
|
+
async runBeforeRenderHooks(ctx, resolver) {
|
|
347
|
+
let currentCtx = ctx;
|
|
348
|
+
for (const hook of this.hookList["beforeRender"]) {
|
|
349
|
+
const hookResult = await hook.hookFn(currentCtx, resolver);
|
|
350
|
+
if (hookResult instanceof Error)
|
|
351
|
+
throw hookResult;
|
|
352
|
+
if (hookResult instanceof LambderResponse)
|
|
353
|
+
return hookResult;
|
|
354
|
+
currentCtx = hookResult;
|
|
355
|
+
}
|
|
356
|
+
return currentCtx;
|
|
357
|
+
}
|
|
460
358
|
async handleNoMatchedAction(ctx, resolver) {
|
|
359
|
+
// Before the fallback hooks, and with the same power it has on a
|
|
360
|
+
// matched route: the fallback hooks are typed void and cannot answer.
|
|
361
|
+
const beforeRenderResult = await this.runBeforeRenderHooks(ctx, resolver);
|
|
362
|
+
if (beforeRenderResult instanceof LambderResponse)
|
|
363
|
+
return beforeRenderResult;
|
|
364
|
+
const currentCtx = beforeRenderResult;
|
|
461
365
|
for (const hook of this.hookList["fallback"]) {
|
|
462
|
-
await hook.hookFn(
|
|
366
|
+
await hook.hookFn(currentCtx, resolver);
|
|
463
367
|
}
|
|
464
|
-
const isAPI =
|
|
368
|
+
const isAPI = currentCtx.api !== null || currentCtx.path === this.apiPath;
|
|
465
369
|
if (isAPI) {
|
|
466
370
|
if (this.apiFallbackHandler)
|
|
467
|
-
return await this.apiFallbackHandler(
|
|
468
|
-
return
|
|
371
|
+
return await this.apiFallbackHandler(currentCtx, resolver);
|
|
372
|
+
return responseFromAnswer(currentCtx.api ? this.pipeline.answerUnknownApi(currentCtx.api, currentCtx) : apiNotFoundAnswer(this.apiVersion, currentCtx.logList));
|
|
469
373
|
}
|
|
470
374
|
if (this.publicFilesHandler) {
|
|
471
|
-
const fileResponse = await this.publicFilesHandler.handle(
|
|
375
|
+
const fileResponse = await this.publicFilesHandler.handle(currentCtx);
|
|
472
376
|
if (fileResponse)
|
|
473
377
|
return fileResponse;
|
|
474
378
|
}
|
|
475
|
-
const indexResponse = await this.
|
|
379
|
+
const indexResponse = this.indexHtmlHandler ? await this.indexHtmlHandler.handle(currentCtx, resolver) : null;
|
|
476
380
|
if (indexResponse)
|
|
477
381
|
return indexResponse;
|
|
478
382
|
if (this.routeFallbackHandler)
|
|
479
|
-
return await this.routeFallbackHandler(
|
|
383
|
+
return await this.routeFallbackHandler(currentCtx, resolver);
|
|
480
384
|
return resolver.text("Not found.", { statusCode: 404 });
|
|
481
385
|
}
|
|
386
|
+
/**
|
|
387
|
+
* True for the OPTIONS request the CORS layer answers by itself. Asked
|
|
388
|
+
* twice: once to build the 204, once at the end of render() to decide
|
|
389
|
+
* which form of the headers goes on. Asking once and letting the tail
|
|
390
|
+
* apply the ordinary headers on top of the 204's would put both forms on
|
|
391
|
+
* a preflight, answering `Vary: Origin, Origin` and an
|
|
392
|
+
* Access-Control-Expose-Headers that means nothing before a request.
|
|
393
|
+
*/
|
|
394
|
+
isCorsPreflight(ctx) {
|
|
395
|
+
return ctx.method === "OPTIONS" && !!this.corsConfig;
|
|
396
|
+
}
|
|
482
397
|
async resolveRequest(ctx, resolver) {
|
|
483
|
-
if (
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
code: LAMBDER_REFUSAL_CODES.invalidRequestPayload,
|
|
501
|
-
content: restored.message,
|
|
502
|
-
},
|
|
503
|
-
}, { statusCode: 400 });
|
|
504
|
-
}
|
|
398
|
+
if (this.isCorsPreflight(ctx))
|
|
399
|
+
return new LambderResponse({ statusCode: 204, body: null });
|
|
400
|
+
if (ctx.api) {
|
|
401
|
+
// The protocol's own pre-pass, run here rather than left to the
|
|
402
|
+
// pipeline so that hooks and route matching see a plain payload,
|
|
403
|
+
// and so a stale client is answered before any of them, whether or
|
|
404
|
+
// not the name it asked for exists.
|
|
405
|
+
const prepared = await this.pipeline.prepare(ctx.api);
|
|
406
|
+
if (prepared)
|
|
407
|
+
return responseFromAnswer(prepared);
|
|
408
|
+
// ctx.post is the raw body view; it shows the restored payload and
|
|
409
|
+
// none of the wire fields, so nothing reading it sees the format.
|
|
410
|
+
ctx.apiPayload = ctx.api.payload;
|
|
411
|
+
ctx.post.payload = ctx.api.payload;
|
|
412
|
+
delete ctx.post[COMPRESSED_PAYLOAD_GZ_FIELD];
|
|
413
|
+
delete ctx.post[COMPRESSED_PAYLOAD_BR_FIELD];
|
|
414
|
+
delete ctx.post[COMPRESSED_PAYLOAD_BYTES_FIELD];
|
|
505
415
|
}
|
|
506
416
|
let matched = null;
|
|
507
417
|
for (const action of this.actionList) {
|
|
@@ -513,23 +423,19 @@ export default class Lambder {
|
|
|
513
423
|
}
|
|
514
424
|
if (!matched)
|
|
515
425
|
return await this.handleNoMatchedAction(ctx, resolver);
|
|
426
|
+
// Set before the hooks run, so a beforeRender hook on a matched route
|
|
427
|
+
// sees the route's own path params.
|
|
516
428
|
ctx.pathParams = matched.params;
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
throw hookResult;
|
|
522
|
-
if (hookResult instanceof LambderResponse)
|
|
523
|
-
return hookResult;
|
|
524
|
-
currentCtx = hookResult;
|
|
525
|
-
}
|
|
526
|
-
return await matched.action.actionFn(currentCtx, resolver);
|
|
429
|
+
const beforeRenderResult = await this.runBeforeRenderHooks(ctx, resolver);
|
|
430
|
+
if (beforeRenderResult instanceof LambderResponse)
|
|
431
|
+
return beforeRenderResult;
|
|
432
|
+
return await matched.action.actionFn(beforeRenderResult, resolver);
|
|
527
433
|
}
|
|
528
434
|
async render(event, lambdaContext) {
|
|
529
435
|
let ctx = null;
|
|
530
436
|
try {
|
|
531
437
|
await this.ensureInitialized();
|
|
532
|
-
ctx = createContext(event, lambdaContext, this.apiPath);
|
|
438
|
+
ctx = createContext(event, lambdaContext, this.apiPath, this.trustedClientIpHeaders);
|
|
533
439
|
const resolver = this.getResolver(ctx);
|
|
534
440
|
let response;
|
|
535
441
|
try {
|
|
@@ -540,15 +446,23 @@ export default class Lambder {
|
|
|
540
446
|
if (err instanceof LambderResponse) {
|
|
541
447
|
response = err;
|
|
542
448
|
}
|
|
543
|
-
// A thrown
|
|
449
|
+
// A thrown LambderApiRefusal on an API call IS a structured refusal
|
|
544
450
|
// (brand-checked, not instanceof, to survive duplicate installs).
|
|
545
|
-
else if (
|
|
546
|
-
response = this.apiErrorResponse(err,
|
|
451
|
+
else if (isLambderApiRefusal(err) && ctx.api) {
|
|
452
|
+
response = this.apiErrorResponse(err, ctx);
|
|
547
453
|
}
|
|
548
454
|
else {
|
|
549
455
|
throw err;
|
|
550
456
|
}
|
|
551
457
|
}
|
|
458
|
+
// What the call wrote goes on BEFORE the hooks run, so an
|
|
459
|
+
// afterRender hook can override or delete a header the handler
|
|
460
|
+
// wrote: replaying the operations afterwards put the handler's
|
|
461
|
+
// value straight back, and a hook could not win an argument it
|
|
462
|
+
// was the last to speak in.
|
|
463
|
+
const responseIntoHooks = response;
|
|
464
|
+
const headersAppliedIntoHooks = ctx.responseHeaders.size;
|
|
465
|
+
ctx.responseHeaders.applyTo(response);
|
|
552
466
|
try {
|
|
553
467
|
for (const hook of this.hookList["afterRender"]) {
|
|
554
468
|
const hookResponse = await hook.hookFn(ctx, resolver, response);
|
|
@@ -561,54 +475,196 @@ export default class Lambder {
|
|
|
561
475
|
if (err instanceof LambderResponse) {
|
|
562
476
|
response = err;
|
|
563
477
|
}
|
|
564
|
-
else if (
|
|
565
|
-
response = this.apiErrorResponse(err,
|
|
478
|
+
else if (isLambderApiRefusal(err) && ctx.api) {
|
|
479
|
+
response = this.apiErrorResponse(err, ctx);
|
|
566
480
|
}
|
|
567
481
|
else {
|
|
568
482
|
throw err;
|
|
569
483
|
}
|
|
570
484
|
}
|
|
571
|
-
//
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
this.applyCors(ctx, response,
|
|
579
|
-
return await finalizeResponse(ctx, response, this.finalizeOptions, ctx.
|
|
485
|
+
// Only what the hooks themselves wrote (res.setHeader inside a
|
|
486
|
+
// hook) is left to apply, which is what leaves their overrides
|
|
487
|
+
// standing. A hook that answered with a DIFFERENT response takes
|
|
488
|
+
// the whole set instead: headers belong to the call rather than to
|
|
489
|
+
// the response that first carried them, so the session cookie the
|
|
490
|
+
// call wrote has to travel across to it.
|
|
491
|
+
ctx.responseHeaders.applyTo(response, response === responseIntoHooks ? headersAppliedIntoHooks : 0);
|
|
492
|
+
this.applyCors(ctx, response, this.isCorsPreflight(ctx));
|
|
493
|
+
return await finalizeResponse(ctx, response, this.finalizeOptions, ctx.eventFormat);
|
|
580
494
|
}
|
|
581
495
|
catch (err) {
|
|
582
|
-
|
|
496
|
+
// Describing the thrown value can itself throw: an object with a
|
|
497
|
+
// null prototype, a Proxy, or a throwing toString/Symbol.toPrimitive.
|
|
498
|
+
// Coercing it unguarded in the FIRST statement of the last-resort
|
|
499
|
+
// catch made the catch throw, so the error handler never ran, no
|
|
500
|
+
// envelope was produced, and the invocation rejected with a 502 no
|
|
501
|
+
// client could parse. coerceToError is the shared version of that
|
|
502
|
+
// care, the one every site in the framework now uses.
|
|
503
|
+
const wrappedError = coerceToError(err, "an unstringifiable thrown value");
|
|
583
504
|
// ctx may be null (createContext failed): derive the format from the raw event.
|
|
584
|
-
const eventFormat = ctx?.
|
|
505
|
+
const eventFormat = ctx?.eventFormat ?? (isV2HttpEvent(event) ? "v2" : "v1");
|
|
585
506
|
try {
|
|
586
507
|
if (this.globalErrorHandler) {
|
|
587
508
|
const responseBuilder = this.getResponseBuilder(ctx ?? undefined);
|
|
588
|
-
const errorResponse = await this.globalErrorHandler(wrappedError, ctx, responseBuilder
|
|
509
|
+
const errorResponse = await this.globalErrorHandler(wrappedError, ctx, responseBuilder);
|
|
510
|
+
// The same rule the success path follows: headers belong
|
|
511
|
+
// to the call, not to the response that first carried
|
|
512
|
+
// them. A call that wrote a session cookie and then threw
|
|
513
|
+
// still owes the browser that cookie, and a cross-origin
|
|
514
|
+
// caller cannot read the error at all without the CORS
|
|
515
|
+
// headers.
|
|
516
|
+
ctx?.responseHeaders.applyTo(errorResponse);
|
|
517
|
+
if (ctx)
|
|
518
|
+
this.applyCors(ctx, errorResponse, false);
|
|
589
519
|
return await finalizeResponse(ctx, errorResponse, this.finalizeOptions, eventFormat);
|
|
590
520
|
}
|
|
591
521
|
}
|
|
592
522
|
catch (handlerErr) {
|
|
593
523
|
if (handlerErr instanceof LambderResponse) {
|
|
524
|
+
ctx?.responseHeaders.applyTo(handlerErr);
|
|
525
|
+
if (ctx)
|
|
526
|
+
this.applyCors(ctx, handlerErr, false);
|
|
594
527
|
try {
|
|
595
528
|
return await finalizeResponse(ctx, handlerErr, this.finalizeOptions, eventFormat);
|
|
596
529
|
}
|
|
597
530
|
catch { /* fall through */ }
|
|
598
531
|
}
|
|
599
532
|
}
|
|
600
|
-
// Last-resort 500. API calls get the
|
|
601
|
-
// parse a structured failure; everything else keeps
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
533
|
+
// Last-resort 500. API calls get the core's crash envelope so
|
|
534
|
+
// clients can parse a structured failure; everything else keeps
|
|
535
|
+
// plain text. Emitted directly rather than finalized, because
|
|
536
|
+
// finalization may be what failed. The headers still go on: they
|
|
537
|
+
// belong to the call and not to the response that first carried
|
|
538
|
+
// them, so a call that wrote a session cookie and then threw still
|
|
539
|
+
// owes the browser that cookie, and a cross-origin caller cannot
|
|
540
|
+
// read this error at all without the CORS headers. Applying them
|
|
541
|
+
// is plain object work, none of the compression, base64 or size
|
|
542
|
+
// handling that finalization does.
|
|
543
|
+
const crashResponse = ctx?.api
|
|
544
|
+
? responseFromAnswer(crashAnswer(this.apiVersion))
|
|
545
|
+
: new LambderResponse({ statusCode: 500, body: "Internal Server Error." });
|
|
546
|
+
ctx?.responseHeaders.applyTo(crashResponse);
|
|
547
|
+
if (ctx)
|
|
548
|
+
this.applyCors(ctx, crashResponse, false);
|
|
549
|
+
return emitResponse(eventFormat, crashResponse.statusCode, crashResponse.headers, typeof crashResponse.body === "string" ? crashResponse.body : "", false);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Fetch the session for a session route or short-circuit it with the
|
|
554
|
+
* sessionExpiredRouteHandler response (default 401). Session APIs never
|
|
555
|
+
* come through here: the pipeline answers them with the protocol's
|
|
556
|
+
* { sessionExpired: true } envelope itself.
|
|
557
|
+
*/
|
|
558
|
+
async requireSession(ctx, resolver) {
|
|
559
|
+
const session = await this.getSessionController(ctx).fetchSessionIfExists();
|
|
560
|
+
if (!session) {
|
|
561
|
+
if (this.sessionExpiredRouteHandler) {
|
|
562
|
+
throw await this.sessionExpiredRouteHandler(ctx, resolver);
|
|
563
|
+
}
|
|
564
|
+
throw resolver.status(401, "Session required.");
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
/** Dispatch a non-HTTP Lambda event to the registered actions. */
|
|
568
|
+
async renderEvent(event, lambdaContext) {
|
|
569
|
+
await this.ensureInitialized();
|
|
570
|
+
for (const action of this.eventActionList) {
|
|
571
|
+
if (action.match(event)) {
|
|
572
|
+
return await action.actionFn(event, lambdaContext);
|
|
607
573
|
}
|
|
608
|
-
return eventFormat === "v2"
|
|
609
|
-
? { statusCode: 500, headers: {}, body: "Internal Server Error.", isBase64Encoded: false }
|
|
610
|
-
: { statusCode: 500, multiValueHeaders: {}, body: "Internal Server Error.", isBase64Encoded: false };
|
|
611
574
|
}
|
|
575
|
+
const summary = event && typeof event === "object"
|
|
576
|
+
? ` (source: ${String(event.source ?? "?")}, detail-type: ${String(event["detail-type"] ?? "?")})`
|
|
577
|
+
: "";
|
|
578
|
+
throw new Error(`Lambder: no action matched non-HTTP event${summary}. Register one with addAction(); a trailing addAction(() => true, ...) acts as a fallback.`);
|
|
579
|
+
}
|
|
580
|
+
// =====================================================================
|
|
581
|
+
// The API path
|
|
582
|
+
// The steps only an API call takes, around the shared core.
|
|
583
|
+
// =====================================================================
|
|
584
|
+
/** Registration-time checks shared by addApi/addSessionApi. */
|
|
585
|
+
assertApiRegistration(name, mode, options) {
|
|
586
|
+
if (this.registeredApiNames.has(name)) {
|
|
587
|
+
throw new Error(`Lambder: duplicate API name "${name}". Dispatch is first-match, so the second registration would be silently dead code.`);
|
|
588
|
+
}
|
|
589
|
+
// Everything that can refuse this registration runs before the name is
|
|
590
|
+
// claimed. Claiming it first meant a caught registration error burned
|
|
591
|
+
// the name, and the retry reported a duplicate instead of the problem
|
|
592
|
+
// the app was fixing; the session check was still on the far side of
|
|
593
|
+
// that line, one method down in addSessionApi.
|
|
594
|
+
if (mode === "session" && !this.pipeline.hasSessions) {
|
|
595
|
+
throw new Error(`Lambder: session API "${name}" needs the session option at creation.`);
|
|
596
|
+
}
|
|
597
|
+
const guardsRequired = mode === "session" ? this.requireSessionApiGuards : this.requirePublicApiGuards;
|
|
598
|
+
if (guardsRequired && options.guards === undefined) {
|
|
599
|
+
const optOut = mode === "session"
|
|
600
|
+
? "the named no-op guard that marks the session itself as the whole authorization"
|
|
601
|
+
: "the named no-op guard that records why anyone may call it";
|
|
602
|
+
throw new Error(`Lambder: ${mode} API "${name}" declares no guards, and require${mode === "session" ? "Session" : "Public"}ApiGuards is on. ` +
|
|
603
|
+
`Declare the guard that authorizes it, or ${optOut}.`);
|
|
604
|
+
}
|
|
605
|
+
this.pipeline.assertRegistration({ name, mode, guards: options.guards, rateLimit: options.rateLimit, idempotency: options.idempotency });
|
|
606
|
+
this.registeredApiNames.add(name);
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* The answer for a rejected input: the app's
|
|
610
|
+
* setApiInputValidationErrorHandler when set, otherwise the standard 422
|
|
611
|
+
* body. The API's own schema and every preflight slice (guard inputs,
|
|
612
|
+
* rate-limit keys) answer through here, so one failure has one shape.
|
|
613
|
+
*/
|
|
614
|
+
async inputValidationRefusal(ctx, zodError) {
|
|
615
|
+
if (this.apiInputValidationErrorHandler) {
|
|
616
|
+
return answerFromResponse(await this.apiInputValidationErrorHandler(ctx, this.getResolver(ctx), zodError));
|
|
617
|
+
}
|
|
618
|
+
// null asks the pipeline for the standard 422, so that answer is
|
|
619
|
+
// written once, in the core, rather than here as well.
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* One API call through the core: the pipeline runs the protocol steps and
|
|
624
|
+
* calls back for the handler, whose LambderResponse (returned, or thrown
|
|
625
|
+
* via res.die.*) becomes the answer the pipeline stores and hands back.
|
|
626
|
+
* The context is the pipeline's context, so a session it fetched is on
|
|
627
|
+
* ctx.session and the validated payload is on ctx.apiPayload when the
|
|
628
|
+
* handler runs.
|
|
629
|
+
*/
|
|
630
|
+
async runApi(ctx, resolver, definition, handler) {
|
|
631
|
+
const request = ctx.api;
|
|
632
|
+
if (!request)
|
|
633
|
+
throw new Error(`Lambder: API "${definition.name}" was matched by a request that is not an API call.`);
|
|
634
|
+
// What the handler produced, in both forms: the answer went to the
|
|
635
|
+
// pipeline, and the response is kept so it can carry on unchanged.
|
|
636
|
+
const handled = { output: null };
|
|
637
|
+
const { answer } = await this.pipeline.run(request, ctx, definition, async () => {
|
|
638
|
+
ctx.apiPayload = request.payload;
|
|
639
|
+
let response;
|
|
640
|
+
try {
|
|
641
|
+
response = await handler(ctx, resolver);
|
|
642
|
+
}
|
|
643
|
+
catch (err) {
|
|
644
|
+
// A thrown LambderResponse IS the response (res.die.*): an
|
|
645
|
+
// answer like a returned one, stored and replayed alike.
|
|
646
|
+
if (err instanceof LambderResponse)
|
|
647
|
+
response = err;
|
|
648
|
+
else
|
|
649
|
+
throw err;
|
|
650
|
+
}
|
|
651
|
+
handled.output = { response, answer: answerFromResponse(response) };
|
|
652
|
+
return handled.output.answer;
|
|
653
|
+
});
|
|
654
|
+
// The handler's own response carries on when the pipeline answered
|
|
655
|
+
// with it, rather than a rebuild of its answer. An answer holds a
|
|
656
|
+
// Buffer body base64-encoded, because that is the plain shape the
|
|
657
|
+
// idempotency store persists, and rebuilding a response from that
|
|
658
|
+
// would hand finalization a base64 string it must pass through
|
|
659
|
+
// uncompressed. Identity is what settles it: the pipeline may have
|
|
660
|
+
// answered with a stored replay or a refusal instead.
|
|
661
|
+
if (handled.output?.answer === answer)
|
|
662
|
+
return handled.output.response;
|
|
663
|
+
return responseFromAnswer(answer);
|
|
664
|
+
}
|
|
665
|
+
/** A thrown LambderApiRefusal (from a hook, say) as the structured API envelope: the core's one mapping. */
|
|
666
|
+
apiErrorResponse(err, ctx) {
|
|
667
|
+
return responseFromAnswer(refusalAnswer(err, this.apiVersion, ctx.logList));
|
|
612
668
|
}
|
|
613
669
|
}
|
|
614
670
|
/**
|
|
@@ -623,7 +679,7 @@ export default class Lambder {
|
|
|
623
679
|
* // app.ts (imports no api modules, so modules can import the type back)
|
|
624
680
|
* export const lambderApp = initLambder<SessionData>().create({
|
|
625
681
|
* apiPath: "/api",
|
|
626
|
-
* session: { tableName: "app-session",
|
|
682
|
+
* session: { store: new LambderDdbSessionStore({ tableName: "app-session", region: "us-east-1" }), sessionSalt: "..." },
|
|
627
683
|
* rateLimits: { limiter, policies },
|
|
628
684
|
* guards,
|
|
629
685
|
* idempotency: { store },
|
|
@@ -651,26 +707,3 @@ export const initLambder = () => ({
|
|
|
651
707
|
return new Lambder(options);
|
|
652
708
|
},
|
|
653
709
|
});
|
|
654
|
-
/** Rebuild the query string from the API Gateway event for redirects. */
|
|
655
|
-
const buildQueryString = (ctx) => {
|
|
656
|
-
if (isV2HttpEvent(ctx.event)) {
|
|
657
|
-
return ctx.event.rawQueryString ? `?${ctx.event.rawQueryString}` : "";
|
|
658
|
-
}
|
|
659
|
-
const multi = ctx.event.multiValueQueryStringParameters;
|
|
660
|
-
const single = ctx.event.queryStringParameters;
|
|
661
|
-
const params = new URLSearchParams();
|
|
662
|
-
if (multi) {
|
|
663
|
-
for (const [key, values] of Object.entries(multi)) {
|
|
664
|
-
for (const value of values ?? [])
|
|
665
|
-
params.append(key, value);
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
else if (single) {
|
|
669
|
-
for (const [key, value] of Object.entries(single)) {
|
|
670
|
-
if (value !== undefined)
|
|
671
|
-
params.append(key, value);
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
const queryString = params.toString();
|
|
675
|
-
return queryString ? `?${queryString}` : "";
|
|
676
|
-
};
|