lambder 7.2.5 → 8.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1021 -3
- package/README.md +43 -21
- package/dist/api/LambderApiAnswer.d.ts +18 -22
- package/dist/api/LambderApiAnswer.js +6 -7
- package/dist/api/LambderApiCallContext.d.ts +21 -8
- package/dist/api/LambderApiCallContext.js +22 -4
- package/dist/api/LambderApiDefinition.d.ts +4 -3
- package/dist/api/LambderApiEnvelope.d.ts +14 -9
- package/dist/api/LambderApiEnvelope.js +33 -34
- package/dist/api/LambderApiGuards.d.ts +78 -51
- package/dist/api/LambderApiGuards.js +34 -36
- package/dist/api/LambderApiIdempotency.d.ts +74 -61
- package/dist/api/LambderApiIdempotency.js +226 -151
- package/dist/api/LambderApiOutputValidationError.d.ts +32 -0
- package/dist/api/LambderApiOutputValidationError.js +50 -0
- package/dist/api/LambderApiPipeline.d.ts +77 -39
- package/dist/api/LambderApiPipeline.js +135 -62
- package/dist/api/LambderApiRateLimits.d.ts +208 -54
- package/dist/api/LambderApiRateLimits.js +197 -108
- package/dist/api/LambderApiRequest.d.ts +27 -21
- package/dist/api/LambderApiRequest.js +26 -19
- package/dist/api/LambderApiSignature.d.ts +12 -15
- package/dist/api/LambderApiSignature.js +28 -51
- package/dist/api/LambderApiValidationRefusal.d.ts +9 -9
- package/dist/api/LambderApiValidationRefusal.js +10 -10
- package/dist/build/freshProcessVerifier.d.ts +13 -0
- package/dist/build/freshProcessVerifier.js +19 -0
- package/dist/build/writeApiSignatures.d.ts +109 -0
- package/dist/build/writeApiSignatures.js +222 -0
- package/dist/build.d.ts +9 -0
- package/dist/build.js +8 -0
- package/dist/client/LambderCaller.d.ts +13 -44
- package/dist/client/LambderCaller.js +77 -84
- package/dist/client/LambderReloadLoopBreaker.d.ts +56 -26
- package/dist/client/LambderReloadLoopBreaker.js +90 -46
- package/dist/client/lambderFetchTransport.d.ts +4 -1
- package/dist/client/lambderFetchTransport.js +52 -28
- package/dist/client.d.ts +5 -3
- package/dist/client.js +2 -1
- package/dist/core/Lambder.d.ts +161 -69
- package/dist/core/Lambder.js +370 -226
- package/dist/core/LambderContext.d.ts +82 -15
- package/dist/core/LambderContext.js +107 -20
- package/dist/core/LambderCors.d.ts +21 -3
- package/dist/core/LambderCors.js +35 -16
- package/dist/core/LambderCrashHandling.d.ts +40 -0
- package/dist/core/LambderCrashHandling.js +97 -0
- package/dist/core/LambderCreateOptions.d.ts +151 -75
- package/dist/core/LambderCreateOptions.js +16 -23
- package/dist/core/LambderFiles.d.ts +28 -7
- package/dist/core/LambderFiles.js +73 -33
- package/dist/core/LambderIndexHtml.js +12 -11
- package/dist/core/LambderPolicyBuilders.d.ts +17 -5
- package/dist/core/LambderPolicyBuilders.js +17 -5
- package/dist/core/LambderPublicFiles.d.ts +11 -5
- package/dist/core/LambderPublicFiles.js +32 -4
- package/dist/core/LambderRequestPath.d.ts +43 -0
- package/dist/core/LambderRequestPath.js +63 -0
- package/dist/core/LambderResponse.d.ts +26 -5
- package/dist/core/LambderResponse.js +157 -70
- package/dist/core/LambderResponseBuilder.d.ts +49 -4
- package/dist/core/LambderResponseBuilder.js +64 -3
- package/dist/core/LambderRouting.d.ts +2 -3
- package/dist/core/LambderRouting.js +22 -7
- package/dist/core/LambderTemplatingEngine.js +211 -32
- package/dist/index.d.ts +15 -8
- package/dist/index.js +5 -4
- package/dist/invoke/LambderInvokeCaller.d.ts +37 -42
- package/dist/invoke/LambderInvokeCaller.js +76 -66
- package/dist/invoke/LambderInvokeOutcome.d.ts +27 -26
- package/dist/invoke/LambderInvokeOutcome.js +9 -22
- package/dist/invoke/LambderLambdaEvent.d.ts +44 -10
- package/dist/invoke/LambderLambdaEvent.js +80 -37
- package/dist/invoke/lambderHandlerTransport.d.ts +12 -10
- package/dist/invoke/lambderHandlerTransport.js +16 -19
- package/dist/mock/LambderMockApp.d.ts +67 -83
- package/dist/mock/LambderMockApp.js +167 -153
- package/dist/mock/LambderMockBrowserCookies.d.ts +24 -28
- package/dist/mock/LambderMockBrowserCookies.js +24 -28
- package/dist/mock/LambderMockCallRecorder.d.ts +15 -22
- package/dist/mock/LambderMockCallRecorder.js +19 -28
- package/dist/mock/LambderMockCreateOptions.d.ts +42 -24
- package/dist/mock/LambderMockEntryRegistry.d.ts +11 -12
- package/dist/mock/LambderMockEntryRegistry.js +24 -29
- package/dist/mock/LambderMockFailureInjector.d.ts +3 -6
- package/dist/mock/LambderMockFailureInjector.js +3 -6
- package/dist/mock/LambderMockTypes.d.ts +78 -108
- package/dist/mock/lambderMockInvokeTransport.d.ts +11 -13
- package/dist/mock/lambderMockInvokeTransport.js +11 -10
- package/dist/mock/lambderMockMswHandler.d.ts +33 -29
- package/dist/mock/lambderMockMswHandler.js +50 -39
- package/dist/mock.d.ts +3 -1
- package/dist/mock.js +5 -3
- package/dist/session/LambderSessionController.d.ts +108 -89
- package/dist/session/LambderSessionController.js +187 -168
- package/dist/session/LambderSessionCrypto.d.ts +16 -7
- package/dist/session/LambderSessionCrypto.js +26 -12
- package/dist/session/LambderSessionManager.d.ts +136 -47
- package/dist/session/LambderSessionManager.js +280 -139
- package/dist/shared/LambderHtml.d.ts +42 -3
- package/dist/shared/LambderHtml.js +127 -7
- package/dist/shared/LambderHtmlPositions.d.ts +173 -0
- package/dist/shared/LambderHtmlPositions.js +652 -0
- package/dist/shared/LambderI18n.d.ts +10 -11
- package/dist/shared/LambderI18n.js +33 -21
- package/dist/shared/contracts/LambderCache.d.ts +66 -0
- package/dist/shared/contracts/LambderCache.js +11 -0
- package/dist/shared/contracts/LambderFileSource.d.ts +6 -6
- package/dist/shared/contracts/LambderFileSource.js +5 -8
- package/dist/shared/contracts/LambderIdempotencyStore.d.ts +51 -22
- package/dist/shared/contracts/LambderIdempotencyStore.js +4 -5
- package/dist/shared/contracts/LambderRateLimiter.d.ts +27 -15
- package/dist/shared/contracts/LambderRateLimiter.js +4 -5
- package/dist/shared/contracts/LambderSessionStore.d.ts +65 -26
- package/dist/shared/contracts/LambderSessionStore.js +5 -6
- package/dist/shared/transport/LambderApiTransport.d.ts +27 -27
- package/dist/shared/transport/LambderApiTransport.js +7 -7
- package/dist/shared/transport/LambderCookieJar.d.ts +28 -35
- package/dist/shared/transport/LambderCookieJar.js +54 -66
- package/dist/shared/transport/lambderCookieJarTransport.d.ts +11 -13
- package/dist/shared/transport/lambderCookieJarTransport.js +24 -23
- package/dist/shared/util/LambderCallAbort.d.ts +5 -5
- package/dist/shared/util/LambderCallAbort.js +5 -5
- package/dist/shared/util/LambderClientIp.d.ts +27 -11
- package/dist/shared/util/LambderClientIp.js +96 -13
- package/dist/shared/util/LambderExpiringMap.d.ts +35 -49
- package/dist/shared/util/LambderExpiringMap.js +41 -57
- package/dist/shared/util/LambderNodeModules.js +6 -7
- package/dist/shared/util/LambderOptionChecks.d.ts +4 -4
- package/dist/shared/util/LambderOptionChecks.js +4 -4
- package/dist/shared/util/LambderResponseBrand.d.ts +5 -5
- package/dist/shared/util/LambderResponseBrand.js +5 -5
- package/dist/shared/util/LambderTestingDoors.d.ts +29 -0
- package/dist/shared/util/LambderTestingDoors.js +29 -0
- package/dist/shared/util/LambderTypeUtilities.d.ts +7 -8
- package/dist/shared/util/LambderTypeUtilities.js +3 -3
- package/dist/shared/util/boundKeyField.d.ts +20 -0
- package/dist/shared/util/boundKeyField.js +34 -0
- package/dist/shared/util/canonicalJson.d.ts +11 -0
- package/dist/shared/util/canonicalJson.js +28 -0
- package/dist/shared/util/joinKeyFields.d.ts +20 -0
- package/dist/shared/util/joinKeyFields.js +22 -0
- package/dist/shared/wire/LambderAnswerHeaders.d.ts +12 -16
- package/dist/shared/wire/LambderAnswerHeaders.js +12 -16
- package/dist/shared/wire/LambderApiContract.d.ts +107 -32
- package/dist/shared/wire/LambderApiOutcome.d.ts +43 -31
- package/dist/shared/wire/LambderApiOutcome.js +48 -23
- package/dist/shared/wire/LambderApiRefusal.d.ts +39 -27
- package/dist/shared/wire/LambderApiRefusal.js +36 -7
- package/dist/shared/wire/LambderApiSignature.d.ts +18 -22
- package/dist/shared/wire/LambderApiSignature.js +16 -19
- package/dist/shared/wire/LambderCallOptions.d.ts +38 -47
- package/dist/shared/wire/LambderCallOptions.js +9 -11
- package/dist/shared/wire/LambderCompressionCodec.d.ts +29 -34
- package/dist/shared/wire/LambderCompressionCodec.js +31 -36
- package/dist/shared/wire/LambderCompressionOption.d.ts +9 -9
- package/dist/shared/wire/LambderCompressionOption.js +9 -9
- package/dist/shared/wire/LambderCrashDetail.d.ts +12 -15
- package/dist/shared/wire/LambderCrashDetail.js +12 -15
- package/dist/shared/wire/LambderDefaultApiPath.d.ts +6 -0
- package/dist/shared/wire/LambderDefaultApiPath.js +6 -0
- package/dist/shared/wire/LambderHttpStatus.d.ts +6 -7
- package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +89 -0
- package/dist/shared/wire/LambderIdempotencyKeyScope.js +146 -0
- package/dist/shared/wire/LambderInvokeApiId.d.ts +27 -0
- package/dist/shared/wire/LambderInvokeApiId.js +27 -0
- package/dist/shared/wire/LambderOutcomeAssertions.d.ts +79 -0
- package/dist/shared/wire/LambderOutcomeAssertions.js +112 -0
- package/dist/shared/wire/LambderRequestPayload.d.ts +18 -20
- package/dist/shared/wire/LambderRequestPayload.js +4 -6
- package/dist/stores/LambderCacheFiller.d.ts +48 -0
- package/dist/stores/LambderCacheFiller.js +119 -0
- package/dist/stores/LambderCacheKeys.d.ts +26 -0
- package/dist/stores/LambderCacheKeys.js +54 -0
- package/dist/stores/LambderCacheValues.d.ts +45 -0
- package/dist/stores/LambderCacheValues.js +74 -0
- package/dist/stores/LambderDdbCache.d.ts +121 -56
- package/dist/stores/LambderDdbCache.js +528 -225
- package/dist/stores/LambderDdbIdempotencyStore.d.ts +33 -22
- package/dist/stores/LambderDdbIdempotencyStore.js +75 -50
- package/dist/stores/LambderDdbRateLimiter.d.ts +76 -20
- package/dist/stores/LambderDdbRateLimiter.js +151 -39
- package/dist/stores/LambderDdbSdk.d.ts +43 -31
- package/dist/stores/LambderDdbSdk.js +79 -33
- package/dist/stores/LambderDdbSessionStore.d.ts +27 -14
- package/dist/stores/LambderDdbSessionStore.js +119 -47
- package/dist/stores/LambderHttpFileSource.d.ts +15 -6
- package/dist/stores/LambderHttpFileSource.js +15 -13
- package/dist/stores/LambderMemoryCache.d.ts +49 -0
- package/dist/stores/LambderMemoryCache.js +113 -0
- package/dist/stores/LambderMemoryIdempotencyStore.d.ts +13 -12
- package/dist/stores/LambderMemoryIdempotencyStore.js +31 -30
- package/dist/stores/LambderMemoryRateLimiter.d.ts +8 -9
- package/dist/stores/LambderMemoryRateLimiter.js +14 -13
- package/dist/stores/LambderMemorySessionStore.d.ts +14 -11
- package/dist/stores/LambderMemorySessionStore.js +38 -19
- package/dist/stores/LambderS3FileSource.d.ts +21 -6
- package/dist/stores/LambderS3FileSource.js +12 -7
- package/dist/testing/LambderTestApp.d.ts +176 -0
- package/dist/testing/LambderTestApp.js +204 -0
- package/dist/testing/LambderTestVisitor.d.ts +153 -0
- package/dist/testing/LambderTestVisitor.js +154 -0
- package/dist/testing.d.ts +27 -0
- package/dist/testing.js +24 -0
- package/package.json +20 -3
- package/dist/api/LambderApiPolicyEngine.d.ts +0 -36
- package/dist/api/LambderApiPolicyEngine.js +0 -77
- package/dist/shared/util/LambderKeyFields.d.ts +0 -32
- package/dist/shared/util/LambderKeyFields.js +0 -34
|
@@ -29,11 +29,26 @@ export class LambderResponse {
|
|
|
29
29
|
this.compress = init.compress ?? "auto";
|
|
30
30
|
this.etag = init.etag ?? "auto";
|
|
31
31
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
/**
|
|
33
|
+
* A copy with its own header lists, for a request to write into. A
|
|
34
|
+
* handler may answer with an object it keeps between requests (a
|
|
35
|
+
* module-level 404), and everything downstream adds headers (cookies,
|
|
36
|
+
* CORS, Vary, Content-Encoding, ETag), which would carry one caller's
|
|
37
|
+
* Set-Cookie to the next. The body is shared: nothing writes into it.
|
|
38
|
+
*/
|
|
39
|
+
copy() {
|
|
40
|
+
return new LambderResponse({
|
|
41
|
+
statusCode: this.statusCode,
|
|
42
|
+
headers: this.headers,
|
|
43
|
+
body: this.body,
|
|
44
|
+
isBodyBase64: this.isBodyBase64,
|
|
45
|
+
compress: this.compress,
|
|
46
|
+
etag: this.etag,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
// The header methods delegate to the core's header helpers, so an answer
|
|
50
|
+
// and a response share one implementation of the case-insensitive lookup,
|
|
51
|
+
// replace and append rules and can never disagree about what they mean.
|
|
37
52
|
getHeader(key) {
|
|
38
53
|
return getAnswerHeader(this.headers, key);
|
|
39
54
|
}
|
|
@@ -67,13 +82,12 @@ export const answerFromResponse = (response) => {
|
|
|
67
82
|
/**
|
|
68
83
|
* An answer's status as the response model spells statuses.
|
|
69
84
|
*
|
|
70
|
-
* LambderHttpStatusCode is an authoring surface: it
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* call site, so the widening is a decision a reader can see.
|
|
85
|
+
* LambderHttpStatusCode is an authoring surface: it lets `res.status(...)`
|
|
86
|
+
* offer the codes an app writes and catch a typo. An answer is plain data
|
|
87
|
+
* that has already left that surface (a persisted replay, a mock's answer),
|
|
88
|
+
* so a code outside the union is no reason to refuse a request the app has
|
|
89
|
+
* already answered. A named function rather than a bare cast at the call
|
|
90
|
+
* site, so the widening is visible to a reader.
|
|
77
91
|
*/
|
|
78
92
|
const httpStatusOfAnswer = (statusCode) => statusCode;
|
|
79
93
|
/** A core answer as the response hooks, CORS and finalization work on. */
|
|
@@ -85,10 +99,10 @@ export const responseFromAnswer = (answer) => new LambderResponse({
|
|
|
85
99
|
compress: answer.compress ?? "auto",
|
|
86
100
|
etag: answer.etag ?? "auto",
|
|
87
101
|
});
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const mime = (contentType
|
|
102
|
+
const mimeOf = (contentType) => (contentType?.split(";")[0] ?? "").trim().toLowerCase();
|
|
103
|
+
/** A content type whose body is text: sent as text when it is valid UTF-8 and not compressed. */
|
|
104
|
+
const isTextContentType = (contentType) => {
|
|
105
|
+
const mime = mimeOf(contentType);
|
|
92
106
|
if (mime.startsWith("text/"))
|
|
93
107
|
return true;
|
|
94
108
|
if (mime.endsWith("+json") || mime.endsWith("+xml"))
|
|
@@ -98,11 +112,21 @@ const isCompressibleContentType = (contentType) => {
|
|
|
98
112
|
"application/javascript",
|
|
99
113
|
"application/x-javascript",
|
|
100
114
|
"application/xml",
|
|
101
|
-
"application/wasm",
|
|
102
|
-
"image/svg+xml",
|
|
103
115
|
"application/lambder-json-stream",
|
|
104
116
|
].includes(mime);
|
|
105
117
|
};
|
|
118
|
+
const isCompressibleContentType = (contentType) => isTextContentType(contentType) || mimeOf(contentType) === "application/wasm";
|
|
119
|
+
/** Strict, and keeping a byte-order mark, so a body that decodes is exactly its bytes as text. */
|
|
120
|
+
const strictUtf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
121
|
+
/** The bytes as text when they are valid UTF-8, or null. */
|
|
122
|
+
const utf8TextOf = (bytes) => {
|
|
123
|
+
try {
|
|
124
|
+
return strictUtf8.decode(bytes);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
};
|
|
106
130
|
const acceptsEncoding = (acceptEncoding, encoding) => {
|
|
107
131
|
if (!acceptEncoding)
|
|
108
132
|
return false;
|
|
@@ -129,7 +153,7 @@ export const DEFAULT_RESPONSE_COMPRESSION_SETTINGS = {
|
|
|
129
153
|
quality: 5,
|
|
130
154
|
};
|
|
131
155
|
export const DEFAULT_FINALIZE_OPTIONS = {
|
|
132
|
-
compression: DEFAULT_RESPONSE_COMPRESSION_SETTINGS,
|
|
156
|
+
compression: { v1: null, v2: DEFAULT_RESPONSE_COMPRESSION_SETTINGS },
|
|
133
157
|
etag: true,
|
|
134
158
|
maxResponseBytes: 5_500_000,
|
|
135
159
|
};
|
|
@@ -137,27 +161,66 @@ export const DEFAULT_FINALIZE_OPTIONS = {
|
|
|
137
161
|
* The headers a 304 leaves behind: they describe a body, and a 304 carries
|
|
138
162
|
* none. Everything else goes with it.
|
|
139
163
|
*
|
|
140
|
-
* A keep-list of cache headers
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
* refuse the 304 it had asked for.
|
|
164
|
+
* A keep-list of cache headers would make revalidation the one exit where the
|
|
165
|
+
* call's headers do not reach the client: a cacheable GET that slides a
|
|
166
|
+
* session cookie would stop refreshing it once the browser held the ETag, and
|
|
167
|
+
* a cross-origin revalidation would lose Access-Control-Allow-Origin, so the
|
|
168
|
+
* browser would refuse the 304 it asked for.
|
|
146
169
|
*/
|
|
147
170
|
const HEADERS_DROPPED_ON_NOT_MODIFIED = ["content-type", "content-length", "content-encoding"];
|
|
171
|
+
/** The hash an ETag is made from, or null where Node's crypto is not available. */
|
|
172
|
+
const bodyHashOf = async (body) => {
|
|
173
|
+
const crypto = await getCrypto();
|
|
174
|
+
if (!crypto)
|
|
175
|
+
return null;
|
|
176
|
+
return crypto.createHash("sha256").update(body).digest("hex").slice(0, 32);
|
|
177
|
+
};
|
|
178
|
+
/** Cache-Control directives that offer a copy to shared caches, or describe that shared copy. */
|
|
179
|
+
const SHARED_CACHE_DIRECTIVES = ["public", "s-maxage", "immutable"];
|
|
180
|
+
/** Cache-Control directives that already keep a whole answer out of shared caches. */
|
|
181
|
+
const PRIVATE_CACHE_DIRECTIVES = ["private", "no-store"];
|
|
182
|
+
/**
|
|
183
|
+
* The headers with their Cache-Control made `private` when the answer sets a
|
|
184
|
+
* cookie. A cookie is one visitor's, and a shared cache (a CDN, a proxy)
|
|
185
|
+
* that stores an answer with its Set-Cookie hands that cookie to everyone it
|
|
186
|
+
* serves the copy to: a hook that issues a guest session, or a session read
|
|
187
|
+
* that slides the cookies, would otherwise send a visitor's session out on a
|
|
188
|
+
* content-hashed asset marked `public, max-age=31536000, immutable`. So
|
|
189
|
+
* `public` gives way to `private`, and `s-maxage` and `immutable` go with
|
|
190
|
+
* it: the first speaks only to shared caches, and the second promises a
|
|
191
|
+
* representation every visitor shares, which an answer carrying one
|
|
192
|
+
* visitor's cookie is not. The visitor's own cache keeps max-age. An answer
|
|
193
|
+
* that already says `private` or `no-store`, or says nothing about caching,
|
|
194
|
+
* is left as it is. Never mutates what it was given.
|
|
195
|
+
*/
|
|
196
|
+
const privateWhenSettingCookies = (headers) => {
|
|
197
|
+
const cacheControl = getAnswerHeader(headers, "cache-control");
|
|
198
|
+
if (!cacheControl?.length || !getAnswerHeader(headers, "set-cookie")?.length)
|
|
199
|
+
return headers;
|
|
200
|
+
const directives = cacheControl.flatMap((value) => value.split(",")).map((directive) => directive.trim()).filter(Boolean);
|
|
201
|
+
const nameOf = (directive) => (directive.split("=")[0] ?? "").trim().toLowerCase();
|
|
202
|
+
if (directives.some((directive) => PRIVATE_CACHE_DIRECTIVES.includes(nameOf(directive))))
|
|
203
|
+
return headers;
|
|
204
|
+
const privateHeaders = { ...headers };
|
|
205
|
+
setAnswerHeader(privateHeaders, "Cache-Control", ["private", ...directives.filter((directive) => !SHARED_CACHE_DIRECTIVES.includes(nameOf(directive)))].join(", "));
|
|
206
|
+
return privateHeaders;
|
|
207
|
+
};
|
|
148
208
|
/**
|
|
149
209
|
* Emit the format-specific Lambda response shape. Exported because the
|
|
150
210
|
* last-resort crash path has to emit without finalizing (finalization may be
|
|
151
|
-
* what failed) and must still get the shape right
|
|
152
|
-
* the
|
|
211
|
+
* what failed) and must still get the shape right, so the v1/v2 split lives
|
|
212
|
+
* in this one place. Being the one exit every answer leaves through (each of
|
|
213
|
+
* finalization's, the 304 included, and the crash path's), it is also where
|
|
214
|
+
* an answer that sets a cookie is made private (privateWhenSettingCookies).
|
|
153
215
|
*/
|
|
154
216
|
export const emitResponse = (format, statusCode, headers, body, isBase64Encoded) => {
|
|
217
|
+
const sentHeaders = privateWhenSettingCookies(headers);
|
|
155
218
|
if (format === "v2") {
|
|
156
219
|
// Payload v2 has no multiValueHeaders: multi-values are comma-joined,
|
|
157
220
|
// except Set-Cookie which uses the dedicated cookies array.
|
|
158
221
|
const singleHeaders = {};
|
|
159
222
|
const cookies = [];
|
|
160
|
-
for (const [key, values] of Object.entries(
|
|
223
|
+
for (const [key, values] of Object.entries(sentHeaders)) {
|
|
161
224
|
if (key.toLowerCase() === "set-cookie")
|
|
162
225
|
cookies.push(...values);
|
|
163
226
|
else
|
|
@@ -165,80 +228,105 @@ export const emitResponse = (format, statusCode, headers, body, isBase64Encoded)
|
|
|
165
228
|
}
|
|
166
229
|
return { statusCode, headers: singleHeaders, cookies, body, isBase64Encoded };
|
|
167
230
|
}
|
|
168
|
-
return { statusCode, multiValueHeaders:
|
|
231
|
+
return { statusCode, multiValueHeaders: sentHeaders, body, isBase64Encoded };
|
|
169
232
|
};
|
|
170
233
|
/**
|
|
171
234
|
* Convert an intermediate LambderResponse into the final Lambda response:
|
|
172
|
-
*
|
|
235
|
+
* compression negotiation (Accept-Encoding), ETag + If-None-Match 304, base64
|
|
173
236
|
* encoding, HEAD body stripping, and Lambda payload size guard. Emits the v1
|
|
174
237
|
* (REST API) or v2 (HTTP API / Function URL) response shape.
|
|
238
|
+
*
|
|
239
|
+
* Text goes out as text and only bytes as base64: a REST API decodes base64
|
|
240
|
+
* only for its binaryMediaTypes, so a stylesheet sent as base64 would reach
|
|
241
|
+
* the browser as base64. The ETag is settled before anything is compressed, so
|
|
242
|
+
* a revalidation that ends in a 304 compresses nothing.
|
|
175
243
|
*/
|
|
176
244
|
export const finalizeResponse = async (
|
|
177
245
|
// ctx.header rather than ctx.headers: the context already carries the
|
|
178
|
-
// case-insensitive lookup, and taking the raw map
|
|
179
|
-
// implementation of it
|
|
246
|
+
// case-insensitive lookup, and taking the raw map would need a second
|
|
247
|
+
// implementation of it here.
|
|
180
248
|
ctx, response, options, format = "v1") => {
|
|
181
249
|
const method = (ctx?.method ?? "GET").toUpperCase();
|
|
182
250
|
if (response.body === null) {
|
|
183
251
|
return emitResponse(format, response.statusCode, response.headers, "", false);
|
|
184
252
|
}
|
|
253
|
+
const etagEnabled = response.etag === true || (response.etag === "auto" &&
|
|
254
|
+
options.etag &&
|
|
255
|
+
response.statusCode === 200 &&
|
|
256
|
+
(method === "GET" || method === "HEAD"));
|
|
257
|
+
/** Tags the response, and answers the 304 when the client already holds this representation. */
|
|
258
|
+
const notModifiedFor = async (hashed, encoding) => {
|
|
259
|
+
if (!etagEnabled)
|
|
260
|
+
return null;
|
|
261
|
+
const hash = await bodyHashOf(hashed);
|
|
262
|
+
if (hash === null)
|
|
263
|
+
return null;
|
|
264
|
+
// One tag per representation: the compressed bytes are not the identity ones.
|
|
265
|
+
const etagValue = encoding ? `"${hash}-${encoding}"` : `"${hash}"`;
|
|
266
|
+
response.setHeader("ETag", etagValue);
|
|
267
|
+
const ifNoneMatch = ctx?.header("if-none-match");
|
|
268
|
+
if (!ifNoneMatch || !ifNoneMatch.split(",").map((s) => s.trim()).includes(etagValue))
|
|
269
|
+
return null;
|
|
270
|
+
const notModifiedHeaders = {};
|
|
271
|
+
for (const [key, values] of Object.entries(response.headers)) {
|
|
272
|
+
if (!HEADERS_DROPPED_ON_NOT_MODIFIED.includes(key.toLowerCase()))
|
|
273
|
+
notModifiedHeaders[key] = values;
|
|
274
|
+
}
|
|
275
|
+
return emitResponse(format, 304, notModifiedHeaders, "", false);
|
|
276
|
+
};
|
|
185
277
|
let outBody;
|
|
186
278
|
let isBase64 = false;
|
|
187
279
|
if (response.isBodyBase64) {
|
|
188
280
|
// Pre-encoded binary content: passes through untouched (no compression).
|
|
189
281
|
outBody = String(response.body);
|
|
190
282
|
isBase64 = true;
|
|
283
|
+
const notModified = await notModifiedFor(outBody, null);
|
|
284
|
+
if (notModified)
|
|
285
|
+
return notModified;
|
|
191
286
|
}
|
|
192
287
|
else {
|
|
193
|
-
|
|
288
|
+
const identity = Buffer.isBuffer(response.body)
|
|
194
289
|
? response.body
|
|
195
290
|
: Buffer.from(String(response.body), "utf8");
|
|
196
291
|
const contentType = response.getHeader("Content-Type")?.[0];
|
|
197
292
|
const alreadyEncoded = !!response.getHeader("Content-Encoding");
|
|
293
|
+
const formatCompression = options.compression[format];
|
|
198
294
|
const eligibleForCompression = !alreadyEncoded && (response.compress === true ||
|
|
199
295
|
(response.compress === "auto" &&
|
|
200
|
-
|
|
201
|
-
|
|
296
|
+
formatCompression !== null &&
|
|
297
|
+
identity.length >= formatCompression.minBytes &&
|
|
202
298
|
isCompressibleContentType(contentType)));
|
|
299
|
+
// compress: true forces compression even with it off, so the
|
|
300
|
+
// settings fall back to the defaults rather than being absent.
|
|
301
|
+
const settings = formatCompression ?? DEFAULT_RESPONSE_COMPRESSION_SETTINGS;
|
|
302
|
+
let encoding = null;
|
|
203
303
|
if (eligibleForCompression) {
|
|
204
304
|
// Vary even when this client didn't accept an encoding, to keep caches correct.
|
|
205
305
|
response.addHeader("Vary", "Accept-Encoding");
|
|
206
|
-
// compress: true forces compression even with it globally off, so
|
|
207
|
-
// the settings fall back to the defaults rather than being absent.
|
|
208
|
-
const settings = options.compression ?? DEFAULT_RESPONSE_COMPRESSION_SETTINGS;
|
|
209
306
|
const acceptEncoding = ctx?.header("accept-encoding");
|
|
210
|
-
|
|
211
|
-
if (encoding) {
|
|
212
|
-
// The same codec, quality and TEXT mode a stored record gets.
|
|
213
|
-
bodyBuffer = await compressText(bodyBuffer, encoding, settings.quality);
|
|
214
|
-
response.setHeader("Content-Encoding", encoding);
|
|
215
|
-
}
|
|
307
|
+
encoding = settings.encodings.find((candidate) => acceptsEncoding(acceptEncoding, candidate)) ?? null;
|
|
216
308
|
}
|
|
217
|
-
|
|
218
|
-
|
|
309
|
+
const notModified = await notModifiedFor(identity, encoding);
|
|
310
|
+
if (notModified)
|
|
311
|
+
return notModified;
|
|
312
|
+
if (encoding) {
|
|
313
|
+
// The same codec, quality and TEXT mode a stored record gets.
|
|
314
|
+
outBody = bytesToBase64(await compressText(identity, encoding, settings.quality));
|
|
219
315
|
isBase64 = true;
|
|
316
|
+
response.setHeader("Content-Encoding", encoding);
|
|
220
317
|
}
|
|
221
318
|
else {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
response.setHeader("ETag", etagValue);
|
|
234
|
-
const ifNoneMatch = ctx?.header("if-none-match");
|
|
235
|
-
if (ifNoneMatch && ifNoneMatch.split(",").map((s) => s.trim()).includes(etagValue)) {
|
|
236
|
-
const notModifiedHeaders = {};
|
|
237
|
-
for (const [key, values] of Object.entries(response.headers)) {
|
|
238
|
-
if (!HEADERS_DROPPED_ON_NOT_MODIFIED.includes(key.toLowerCase()))
|
|
239
|
-
notModifiedHeaders[key] = values;
|
|
240
|
-
}
|
|
241
|
-
return emitResponse(format, 304, notModifiedHeaders, "", false);
|
|
319
|
+
const text = alreadyEncoded
|
|
320
|
+
? null
|
|
321
|
+
: Buffer.isBuffer(response.body)
|
|
322
|
+
? (isTextContentType(contentType) ? utf8TextOf(identity) : null)
|
|
323
|
+
: String(response.body);
|
|
324
|
+
if (text !== null) {
|
|
325
|
+
outBody = text;
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
outBody = bytesToBase64(identity);
|
|
329
|
+
isBase64 = true;
|
|
242
330
|
}
|
|
243
331
|
}
|
|
244
332
|
}
|
|
@@ -246,10 +334,9 @@ ctx, response, options, format = "v1") => {
|
|
|
246
334
|
return emitResponse(format, response.statusCode, response.headers, "", false);
|
|
247
335
|
}
|
|
248
336
|
// What Lambda weighs is bytes. A base64 body is ASCII, so its length is
|
|
249
|
-
// its byte count; a
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
// payload-size error and no envelope.
|
|
337
|
+
// its byte count; a UTF-8 one is not, and counting UTF-16 code units would
|
|
338
|
+
// under-report a non-ASCII body by up to 3x, passing a body Lambda then
|
|
339
|
+
// refuses with an opaque payload-size error and no envelope.
|
|
253
340
|
const outBytes = isBase64 ? outBody.length : Buffer.byteLength(outBody, "utf8");
|
|
254
341
|
if (outBytes > options.maxResponseBytes) {
|
|
255
342
|
throw new Error(`Lambder: final response body is ${outBytes} bytes which exceeds the configured ` +
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
1
2
|
import type { LambderRenderContext } from "./LambderContext.js";
|
|
2
3
|
import { type LambderCookieOptions, type LambderClearCookieOptions } from "../shared/wire/LambderCookie.js";
|
|
3
4
|
import type { LambderFiles } from "./LambderFiles.js";
|
|
@@ -22,9 +23,9 @@ export type LambderResponseOptions = {
|
|
|
22
23
|
* `null` beside a config that says why (a refusal flag, an `errorMessage`, a
|
|
23
24
|
* `message`). A bare `res.api(null)` compiles only when the output type
|
|
24
25
|
* itself allows null, so a success payload is always the declared output,
|
|
25
|
-
* which
|
|
26
|
-
*
|
|
27
|
-
*
|
|
26
|
+
* which lets a typed caller (LambderInvokeCaller.api) promise it. Untyped
|
|
27
|
+
* resolvers (`TOutput = any`) accept anything. This is the resolver's method
|
|
28
|
+
* type; the core's answer type is LambderApiAnswer.
|
|
28
29
|
*/
|
|
29
30
|
export type LambderResolverApiMethod<TOutput, TResult> = {
|
|
30
31
|
(payload: TOutput, config?: LambderApiResponseConfig, options?: LambderResponseOptions): TResult;
|
|
@@ -43,10 +44,13 @@ export default class LambderResponseBuilder<TResponse = any> {
|
|
|
43
44
|
protected files: LambderFiles | null;
|
|
44
45
|
protected apiVersion: string | null;
|
|
45
46
|
protected ctx?: LambderRenderContext;
|
|
46
|
-
|
|
47
|
+
/** The output schema of the API this builder answers, which every success payload is parsed through; null outside an API handler. */
|
|
48
|
+
protected apiOutput: z.ZodType | null;
|
|
49
|
+
constructor({ files, apiVersion, ctx, apiOutput }: {
|
|
47
50
|
files?: LambderFiles | null;
|
|
48
51
|
apiVersion?: string | null;
|
|
49
52
|
ctx?: LambderRenderContext;
|
|
53
|
+
apiOutput?: z.ZodType;
|
|
50
54
|
});
|
|
51
55
|
private buildResponse;
|
|
52
56
|
/** The instance's file reader, which res.file and res.templateFile need. */
|
|
@@ -76,6 +80,19 @@ export default class LambderResponseBuilder<TResponse = any> {
|
|
|
76
80
|
html(data: string | LambderSafeHtml, options?: LambderResponseOptions): LambderResponse;
|
|
77
81
|
status(statusCode: LambderHttpStatusCode, body?: string, options?: LambderResponseOptions): LambderResponse;
|
|
78
82
|
status404(data: string, options?: LambderResponseOptions): LambderResponse;
|
|
83
|
+
/**
|
|
84
|
+
* A redirect to `url`, which may be a path or a whole URL. A path stays on
|
|
85
|
+
* this origin: a leading run of slashes and backslashes collapses to one
|
|
86
|
+
* slash, since `//evil.example` is a protocol-relative URL and a browser
|
|
87
|
+
* reads `/\evil.example` as the same thing, so a path built from a
|
|
88
|
+
* decoded ctx.path cannot send the visitor to another host. Another host
|
|
89
|
+
* is named with its scheme. What a URL may not carry as it is (control
|
|
90
|
+
* characters, a space, a backslash, anything outside ASCII) is
|
|
91
|
+
* percent-encoded for every caller: a browser drops a TAB or line break
|
|
92
|
+
* inside a Location, so `/<TAB>/evil.example` would otherwise be that
|
|
93
|
+
* host, and a line break would end the header. `%` is left alone, so an
|
|
94
|
+
* encoded URL stays as it was written.
|
|
95
|
+
*/
|
|
79
96
|
redirect(url: string, statusCode?: LambderHttpStatusCode, options?: LambderResponseOptions): LambderResponse;
|
|
80
97
|
versionExpired(options?: LambderResponseOptions): LambderResponse;
|
|
81
98
|
fileBase64(fileBase64: string, mimeType: string, options?: LambderResponseOptions): LambderResponse;
|
|
@@ -94,6 +111,34 @@ export default class LambderResponseBuilder<TResponse = any> {
|
|
|
94
111
|
}): Promise<LambderResponse>;
|
|
95
112
|
api(payload: TResponse, config?: LambderApiResponseConfig, options?: LambderResponseOptions): LambderResponse;
|
|
96
113
|
api(payload: null, config: LambderApiNullAnswerConfig, options?: LambderResponseOptions): LambderResponse;
|
|
114
|
+
/**
|
|
115
|
+
* A payload as the API's output schema declares it. The type system
|
|
116
|
+
* accepts a value that carries more than the schema (a row read straight
|
|
117
|
+
* from a table is assignable to a narrower object type), and without this
|
|
118
|
+
* the extra fields, a password hash included, would reach the client.
|
|
119
|
+
* zod strips what the schema does not declare, fills its defaults and
|
|
120
|
+
* applies its transforms, so the wire and the idempotency store only see
|
|
121
|
+
* the declared shape. A refusal's payload beside an errorMessage or a
|
|
122
|
+
* flag is parsed the same way; only null passes as it is. Only an API
|
|
123
|
+
* handler's own resolver holds the schema: a hook, a validation handler
|
|
124
|
+
* or an error handler answers in shapes of its own, a cached answer in
|
|
125
|
+
* its wire form, and is sent as given.
|
|
126
|
+
*
|
|
127
|
+
* The payload is the schema's input form (what a handler writes before
|
|
128
|
+
* the transforms), so a transform runs exactly once. A payload the schema
|
|
129
|
+
* rejects is a handler breaking its contract, answered as a crash rather
|
|
130
|
+
* than sent (LambderApiOutputValidationError, which an idempotency key
|
|
131
|
+
* records as its answer, since the handler has already run).
|
|
132
|
+
*
|
|
133
|
+
* The parse is synchronous, so an output schema cannot be async: zod
|
|
134
|
+
* throws from a synchronous parse that meets an async refinement or
|
|
135
|
+
* transform, and a transform may throw of its own accord. Either throw
|
|
136
|
+
* becomes the same LambderApiOutputValidationError, carrying what was
|
|
137
|
+
* thrown as its cause. Left to escape as it is, it would read as the
|
|
138
|
+
* handler crashing before its answer: the idempotency engine would
|
|
139
|
+
* release the key's claim and every retry would run the operation again.
|
|
140
|
+
*/
|
|
141
|
+
private declaredPayload;
|
|
97
142
|
/** Same as api() but forces compression of the response body. */
|
|
98
143
|
apiBinary(payload: TResponse, config?: LambderApiResponseConfig, options?: LambderResponseOptions): LambderResponse;
|
|
99
144
|
apiBinary(payload: null, config: LambderApiNullAnswerConfig, options?: LambderResponseOptions): LambderResponse;
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { serializeCookie, serializeClearCookie } from "../shared/wire/LambderCookie.js";
|
|
2
2
|
import { LambderResponse } from "./LambderResponse.js";
|
|
3
3
|
import { buildApiEnvelope } from "../api/LambderApiEnvelope.js";
|
|
4
|
+
import { LambderApiOutputValidationError } from "../api/LambderApiOutputValidationError.js";
|
|
4
5
|
export default class LambderResponseBuilder {
|
|
5
6
|
files;
|
|
6
7
|
apiVersion;
|
|
7
8
|
ctx;
|
|
8
|
-
|
|
9
|
+
/** The output schema of the API this builder answers, which every success payload is parsed through; null outside an API handler. */
|
|
10
|
+
apiOutput;
|
|
11
|
+
constructor({ files, apiVersion, ctx, apiOutput }) {
|
|
9
12
|
this.files = files ?? null;
|
|
10
13
|
this.apiVersion = apiVersion ?? null;
|
|
11
14
|
this.ctx = ctx;
|
|
15
|
+
this.apiOutput = apiOutput ?? null;
|
|
12
16
|
}
|
|
13
17
|
;
|
|
14
18
|
buildResponse(statusCode, contentType, body, options, defaults) {
|
|
@@ -109,9 +113,24 @@ export default class LambderResponseBuilder {
|
|
|
109
113
|
return this.buildResponse(404, "text/html; charset=utf-8", data, options);
|
|
110
114
|
}
|
|
111
115
|
;
|
|
116
|
+
/**
|
|
117
|
+
* A redirect to `url`, which may be a path or a whole URL. A path stays on
|
|
118
|
+
* this origin: a leading run of slashes and backslashes collapses to one
|
|
119
|
+
* slash, since `//evil.example` is a protocol-relative URL and a browser
|
|
120
|
+
* reads `/\evil.example` as the same thing, so a path built from a
|
|
121
|
+
* decoded ctx.path cannot send the visitor to another host. Another host
|
|
122
|
+
* is named with its scheme. What a URL may not carry as it is (control
|
|
123
|
+
* characters, a space, a backslash, anything outside ASCII) is
|
|
124
|
+
* percent-encoded for every caller: a browser drops a TAB or line break
|
|
125
|
+
* inside a Location, so `/<TAB>/evil.example` would otherwise be that
|
|
126
|
+
* host, and a line break would end the header. `%` is left alone, so an
|
|
127
|
+
* encoded URL stays as it was written.
|
|
128
|
+
*/
|
|
112
129
|
redirect(url, statusCode = 302, options) {
|
|
113
130
|
const response = this.buildResponse(statusCode, null, null, options);
|
|
114
|
-
|
|
131
|
+
const target = /^[a-z][a-z0-9+.-]*:/iu.test(url) ? url : url.replace(/^[/\\]+/u, "/");
|
|
132
|
+
// Everything outside printable ASCII (`!` to `~`), and the backslash.
|
|
133
|
+
response.setHeader("Location", target.replace(/[^!-~]|\\/gu, (character) => encodeURIComponent(character)));
|
|
115
134
|
return response;
|
|
116
135
|
}
|
|
117
136
|
;
|
|
@@ -162,10 +181,52 @@ export default class LambderResponseBuilder {
|
|
|
162
181
|
// The envelope is the core's (one writer for both the server and the
|
|
163
182
|
// mock runtime); the logList channel is what this request accumulated
|
|
164
183
|
// unless the config names its own.
|
|
165
|
-
const envelope = buildApiEnvelope(this.apiVersion, payload, { ...config, logList: config.logList || this.ctx?.logList });
|
|
184
|
+
const envelope = buildApiEnvelope(this.apiVersion, this.declaredPayload(payload), { ...config, logList: config.logList || this.ctx?.logList });
|
|
166
185
|
return this.json(envelope, options);
|
|
167
186
|
}
|
|
168
187
|
;
|
|
188
|
+
/**
|
|
189
|
+
* A payload as the API's output schema declares it. The type system
|
|
190
|
+
* accepts a value that carries more than the schema (a row read straight
|
|
191
|
+
* from a table is assignable to a narrower object type), and without this
|
|
192
|
+
* the extra fields, a password hash included, would reach the client.
|
|
193
|
+
* zod strips what the schema does not declare, fills its defaults and
|
|
194
|
+
* applies its transforms, so the wire and the idempotency store only see
|
|
195
|
+
* the declared shape. A refusal's payload beside an errorMessage or a
|
|
196
|
+
* flag is parsed the same way; only null passes as it is. Only an API
|
|
197
|
+
* handler's own resolver holds the schema: a hook, a validation handler
|
|
198
|
+
* or an error handler answers in shapes of its own, a cached answer in
|
|
199
|
+
* its wire form, and is sent as given.
|
|
200
|
+
*
|
|
201
|
+
* The payload is the schema's input form (what a handler writes before
|
|
202
|
+
* the transforms), so a transform runs exactly once. A payload the schema
|
|
203
|
+
* rejects is a handler breaking its contract, answered as a crash rather
|
|
204
|
+
* than sent (LambderApiOutputValidationError, which an idempotency key
|
|
205
|
+
* records as its answer, since the handler has already run).
|
|
206
|
+
*
|
|
207
|
+
* The parse is synchronous, so an output schema cannot be async: zod
|
|
208
|
+
* throws from a synchronous parse that meets an async refinement or
|
|
209
|
+
* transform, and a transform may throw of its own accord. Either throw
|
|
210
|
+
* becomes the same LambderApiOutputValidationError, carrying what was
|
|
211
|
+
* thrown as its cause. Left to escape as it is, it would read as the
|
|
212
|
+
* handler crashing before its answer: the idempotency engine would
|
|
213
|
+
* release the key's claim and every retry would run the operation again.
|
|
214
|
+
*/
|
|
215
|
+
declaredPayload(payload) {
|
|
216
|
+
if (!this.apiOutput || payload === null)
|
|
217
|
+
return payload;
|
|
218
|
+
const apiName = this.ctx?.apiName ?? "?";
|
|
219
|
+
let parsed;
|
|
220
|
+
try {
|
|
221
|
+
parsed = this.apiOutput.safeParse(payload);
|
|
222
|
+
}
|
|
223
|
+
catch (thrown) {
|
|
224
|
+
throw new LambderApiOutputValidationError(apiName, { thrown });
|
|
225
|
+
}
|
|
226
|
+
if (parsed.success)
|
|
227
|
+
return parsed.data;
|
|
228
|
+
throw new LambderApiOutputValidationError(apiName, { zodError: parsed.error });
|
|
229
|
+
}
|
|
169
230
|
apiBinary(payload, config = {}, options) {
|
|
170
231
|
return this.api(payload, config, { ...options, compress: true });
|
|
171
232
|
}
|
|
@@ -24,9 +24,8 @@ export type CompiledMatcher = (ctx: LambderRenderContext) => false | Record<stri
|
|
|
24
24
|
* unless the list names HEAD itself: a HEAD is a GET whose body finalization
|
|
25
25
|
* strips, so an app that narrowed a slot to ["GET"] did not mean to 404 it.
|
|
26
26
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* slots cannot disagree about what a method means.
|
|
27
|
+
* A route matcher's `method`, servePublicFiles and serveIndexHtml all use
|
|
28
|
+
* this rule, so neighbouring slots cannot disagree about what a method means.
|
|
30
29
|
*/
|
|
31
30
|
export declare const allowsRequestMethod: (methods: ReadonlySet<string>, requestMethod: string) => boolean;
|
|
32
31
|
/** Compile a route condition once at registration time. */
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import { match as pathToRegexpMatch } from "path-to-regexp";
|
|
2
|
+
import { decodePathParam } from "./LambderRequestPath.js";
|
|
2
3
|
const compilePathMatcher = (path) => {
|
|
3
4
|
if (typeof path === "string") {
|
|
4
|
-
|
|
5
|
+
// ctx.path is already decoded, so decoding a param again would read a
|
|
6
|
+
// literal "%41" as "A". decodePathParam only turns the two escapes
|
|
7
|
+
// ctx.path keeps back into "/" and "%". The slash is the only delimiter:
|
|
8
|
+
// a decoded path carries "#" and "?" as text ("/tags/C%23" is the tag
|
|
9
|
+
// "C#"), and path-to-regexp's default would end a param at them.
|
|
10
|
+
// Case-sensitive, as API Gateway routes and CloudFront behaviors are:
|
|
11
|
+
// matched without case, `/ADMIN/users` would miss an authorizer on
|
|
12
|
+
// `/admin/*` in front of the function and still reach "/admin/:x".
|
|
13
|
+
const matchFn = pathToRegexpMatch(path, { decode: decodePathParam, delimiter: "/", sensitive: true });
|
|
5
14
|
return (requestPath) => {
|
|
6
15
|
const result = matchFn(requestPath);
|
|
7
16
|
if (!result)
|
|
@@ -13,16 +22,23 @@ const compilePathMatcher = (path) => {
|
|
|
13
22
|
return params;
|
|
14
23
|
};
|
|
15
24
|
}
|
|
25
|
+
// A RegExp matches ctx.path as written, its kept %2F and %25 included;
|
|
26
|
+
// what it captures is handed over turned back, as a string route's params are.
|
|
16
27
|
return (requestPath) => {
|
|
17
28
|
const matched = requestPath.match(path);
|
|
18
29
|
if (!matched)
|
|
19
30
|
return false;
|
|
20
|
-
if (matched.groups)
|
|
21
|
-
return { ...matched.groups };
|
|
22
31
|
const params = {};
|
|
32
|
+
if (matched.groups) {
|
|
33
|
+
for (const [key, value] of Object.entries(matched.groups)) {
|
|
34
|
+
if (value !== undefined)
|
|
35
|
+
params[key] = decodePathParam(value);
|
|
36
|
+
}
|
|
37
|
+
return params;
|
|
38
|
+
}
|
|
23
39
|
matched.forEach((value, index) => {
|
|
24
40
|
if (value !== undefined)
|
|
25
|
-
params[String(index)] = value;
|
|
41
|
+
params[String(index)] = decodePathParam(value);
|
|
26
42
|
});
|
|
27
43
|
return params;
|
|
28
44
|
};
|
|
@@ -32,9 +48,8 @@ const compilePathMatcher = (path) => {
|
|
|
32
48
|
* unless the list names HEAD itself: a HEAD is a GET whose body finalization
|
|
33
49
|
* strips, so an app that narrowed a slot to ["GET"] did not mean to 404 it.
|
|
34
50
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* slots cannot disagree about what a method means.
|
|
51
|
+
* A route matcher's `method`, servePublicFiles and serveIndexHtml all use
|
|
52
|
+
* this rule, so neighbouring slots cannot disagree about what a method means.
|
|
38
53
|
*/
|
|
39
54
|
export const allowsRequestMethod = (methods, requestMethod) => {
|
|
40
55
|
const method = requestMethod.toUpperCase();
|