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.
Files changed (209) hide show
  1. package/CHANGELOG.md +1021 -3
  2. package/README.md +43 -21
  3. package/dist/api/LambderApiAnswer.d.ts +18 -22
  4. package/dist/api/LambderApiAnswer.js +6 -7
  5. package/dist/api/LambderApiCallContext.d.ts +21 -8
  6. package/dist/api/LambderApiCallContext.js +22 -4
  7. package/dist/api/LambderApiDefinition.d.ts +4 -3
  8. package/dist/api/LambderApiEnvelope.d.ts +14 -9
  9. package/dist/api/LambderApiEnvelope.js +33 -34
  10. package/dist/api/LambderApiGuards.d.ts +78 -51
  11. package/dist/api/LambderApiGuards.js +34 -36
  12. package/dist/api/LambderApiIdempotency.d.ts +74 -61
  13. package/dist/api/LambderApiIdempotency.js +226 -151
  14. package/dist/api/LambderApiOutputValidationError.d.ts +32 -0
  15. package/dist/api/LambderApiOutputValidationError.js +50 -0
  16. package/dist/api/LambderApiPipeline.d.ts +77 -39
  17. package/dist/api/LambderApiPipeline.js +135 -62
  18. package/dist/api/LambderApiRateLimits.d.ts +208 -54
  19. package/dist/api/LambderApiRateLimits.js +197 -108
  20. package/dist/api/LambderApiRequest.d.ts +27 -21
  21. package/dist/api/LambderApiRequest.js +26 -19
  22. package/dist/api/LambderApiSignature.d.ts +12 -15
  23. package/dist/api/LambderApiSignature.js +28 -51
  24. package/dist/api/LambderApiValidationRefusal.d.ts +9 -9
  25. package/dist/api/LambderApiValidationRefusal.js +10 -10
  26. package/dist/build/freshProcessVerifier.d.ts +13 -0
  27. package/dist/build/freshProcessVerifier.js +19 -0
  28. package/dist/build/writeApiSignatures.d.ts +109 -0
  29. package/dist/build/writeApiSignatures.js +222 -0
  30. package/dist/build.d.ts +9 -0
  31. package/dist/build.js +8 -0
  32. package/dist/client/LambderCaller.d.ts +13 -44
  33. package/dist/client/LambderCaller.js +77 -84
  34. package/dist/client/LambderReloadLoopBreaker.d.ts +56 -26
  35. package/dist/client/LambderReloadLoopBreaker.js +90 -46
  36. package/dist/client/lambderFetchTransport.d.ts +4 -1
  37. package/dist/client/lambderFetchTransport.js +52 -28
  38. package/dist/client.d.ts +5 -3
  39. package/dist/client.js +2 -1
  40. package/dist/core/Lambder.d.ts +161 -69
  41. package/dist/core/Lambder.js +370 -226
  42. package/dist/core/LambderContext.d.ts +82 -15
  43. package/dist/core/LambderContext.js +107 -20
  44. package/dist/core/LambderCors.d.ts +21 -3
  45. package/dist/core/LambderCors.js +35 -16
  46. package/dist/core/LambderCrashHandling.d.ts +40 -0
  47. package/dist/core/LambderCrashHandling.js +97 -0
  48. package/dist/core/LambderCreateOptions.d.ts +151 -75
  49. package/dist/core/LambderCreateOptions.js +16 -23
  50. package/dist/core/LambderFiles.d.ts +28 -7
  51. package/dist/core/LambderFiles.js +73 -33
  52. package/dist/core/LambderIndexHtml.js +12 -11
  53. package/dist/core/LambderPolicyBuilders.d.ts +17 -5
  54. package/dist/core/LambderPolicyBuilders.js +17 -5
  55. package/dist/core/LambderPublicFiles.d.ts +11 -5
  56. package/dist/core/LambderPublicFiles.js +32 -4
  57. package/dist/core/LambderRequestPath.d.ts +43 -0
  58. package/dist/core/LambderRequestPath.js +63 -0
  59. package/dist/core/LambderResponse.d.ts +26 -5
  60. package/dist/core/LambderResponse.js +157 -70
  61. package/dist/core/LambderResponseBuilder.d.ts +49 -4
  62. package/dist/core/LambderResponseBuilder.js +64 -3
  63. package/dist/core/LambderRouting.d.ts +2 -3
  64. package/dist/core/LambderRouting.js +22 -7
  65. package/dist/core/LambderTemplatingEngine.js +211 -32
  66. package/dist/index.d.ts +15 -8
  67. package/dist/index.js +5 -4
  68. package/dist/invoke/LambderInvokeCaller.d.ts +37 -42
  69. package/dist/invoke/LambderInvokeCaller.js +76 -66
  70. package/dist/invoke/LambderInvokeOutcome.d.ts +27 -26
  71. package/dist/invoke/LambderInvokeOutcome.js +9 -22
  72. package/dist/invoke/LambderLambdaEvent.d.ts +44 -10
  73. package/dist/invoke/LambderLambdaEvent.js +80 -37
  74. package/dist/invoke/lambderHandlerTransport.d.ts +12 -10
  75. package/dist/invoke/lambderHandlerTransport.js +16 -19
  76. package/dist/mock/LambderMockApp.d.ts +67 -83
  77. package/dist/mock/LambderMockApp.js +167 -153
  78. package/dist/mock/LambderMockBrowserCookies.d.ts +24 -28
  79. package/dist/mock/LambderMockBrowserCookies.js +24 -28
  80. package/dist/mock/LambderMockCallRecorder.d.ts +15 -22
  81. package/dist/mock/LambderMockCallRecorder.js +19 -28
  82. package/dist/mock/LambderMockCreateOptions.d.ts +42 -24
  83. package/dist/mock/LambderMockEntryRegistry.d.ts +11 -12
  84. package/dist/mock/LambderMockEntryRegistry.js +24 -29
  85. package/dist/mock/LambderMockFailureInjector.d.ts +3 -6
  86. package/dist/mock/LambderMockFailureInjector.js +3 -6
  87. package/dist/mock/LambderMockTypes.d.ts +78 -108
  88. package/dist/mock/lambderMockInvokeTransport.d.ts +11 -13
  89. package/dist/mock/lambderMockInvokeTransport.js +11 -10
  90. package/dist/mock/lambderMockMswHandler.d.ts +33 -29
  91. package/dist/mock/lambderMockMswHandler.js +50 -39
  92. package/dist/mock.d.ts +3 -1
  93. package/dist/mock.js +5 -3
  94. package/dist/session/LambderSessionController.d.ts +108 -89
  95. package/dist/session/LambderSessionController.js +187 -168
  96. package/dist/session/LambderSessionCrypto.d.ts +16 -7
  97. package/dist/session/LambderSessionCrypto.js +26 -12
  98. package/dist/session/LambderSessionManager.d.ts +136 -47
  99. package/dist/session/LambderSessionManager.js +280 -139
  100. package/dist/shared/LambderHtml.d.ts +42 -3
  101. package/dist/shared/LambderHtml.js +127 -7
  102. package/dist/shared/LambderHtmlPositions.d.ts +173 -0
  103. package/dist/shared/LambderHtmlPositions.js +652 -0
  104. package/dist/shared/LambderI18n.d.ts +10 -11
  105. package/dist/shared/LambderI18n.js +33 -21
  106. package/dist/shared/contracts/LambderCache.d.ts +66 -0
  107. package/dist/shared/contracts/LambderCache.js +11 -0
  108. package/dist/shared/contracts/LambderFileSource.d.ts +6 -6
  109. package/dist/shared/contracts/LambderFileSource.js +5 -8
  110. package/dist/shared/contracts/LambderIdempotencyStore.d.ts +51 -22
  111. package/dist/shared/contracts/LambderIdempotencyStore.js +4 -5
  112. package/dist/shared/contracts/LambderRateLimiter.d.ts +27 -15
  113. package/dist/shared/contracts/LambderRateLimiter.js +4 -5
  114. package/dist/shared/contracts/LambderSessionStore.d.ts +65 -26
  115. package/dist/shared/contracts/LambderSessionStore.js +5 -6
  116. package/dist/shared/transport/LambderApiTransport.d.ts +27 -27
  117. package/dist/shared/transport/LambderApiTransport.js +7 -7
  118. package/dist/shared/transport/LambderCookieJar.d.ts +28 -35
  119. package/dist/shared/transport/LambderCookieJar.js +54 -66
  120. package/dist/shared/transport/lambderCookieJarTransport.d.ts +11 -13
  121. package/dist/shared/transport/lambderCookieJarTransport.js +24 -23
  122. package/dist/shared/util/LambderCallAbort.d.ts +5 -5
  123. package/dist/shared/util/LambderCallAbort.js +5 -5
  124. package/dist/shared/util/LambderClientIp.d.ts +27 -11
  125. package/dist/shared/util/LambderClientIp.js +96 -13
  126. package/dist/shared/util/LambderExpiringMap.d.ts +35 -49
  127. package/dist/shared/util/LambderExpiringMap.js +41 -57
  128. package/dist/shared/util/LambderNodeModules.js +6 -7
  129. package/dist/shared/util/LambderOptionChecks.d.ts +4 -4
  130. package/dist/shared/util/LambderOptionChecks.js +4 -4
  131. package/dist/shared/util/LambderResponseBrand.d.ts +5 -5
  132. package/dist/shared/util/LambderResponseBrand.js +5 -5
  133. package/dist/shared/util/LambderTestingDoors.d.ts +29 -0
  134. package/dist/shared/util/LambderTestingDoors.js +29 -0
  135. package/dist/shared/util/LambderTypeUtilities.d.ts +7 -8
  136. package/dist/shared/util/LambderTypeUtilities.js +3 -3
  137. package/dist/shared/util/boundKeyField.d.ts +20 -0
  138. package/dist/shared/util/boundKeyField.js +34 -0
  139. package/dist/shared/util/canonicalJson.d.ts +11 -0
  140. package/dist/shared/util/canonicalJson.js +28 -0
  141. package/dist/shared/util/joinKeyFields.d.ts +20 -0
  142. package/dist/shared/util/joinKeyFields.js +22 -0
  143. package/dist/shared/wire/LambderAnswerHeaders.d.ts +12 -16
  144. package/dist/shared/wire/LambderAnswerHeaders.js +12 -16
  145. package/dist/shared/wire/LambderApiContract.d.ts +107 -32
  146. package/dist/shared/wire/LambderApiOutcome.d.ts +43 -31
  147. package/dist/shared/wire/LambderApiOutcome.js +48 -23
  148. package/dist/shared/wire/LambderApiRefusal.d.ts +39 -27
  149. package/dist/shared/wire/LambderApiRefusal.js +36 -7
  150. package/dist/shared/wire/LambderApiSignature.d.ts +18 -22
  151. package/dist/shared/wire/LambderApiSignature.js +16 -19
  152. package/dist/shared/wire/LambderCallOptions.d.ts +38 -47
  153. package/dist/shared/wire/LambderCallOptions.js +9 -11
  154. package/dist/shared/wire/LambderCompressionCodec.d.ts +29 -34
  155. package/dist/shared/wire/LambderCompressionCodec.js +31 -36
  156. package/dist/shared/wire/LambderCompressionOption.d.ts +9 -9
  157. package/dist/shared/wire/LambderCompressionOption.js +9 -9
  158. package/dist/shared/wire/LambderCrashDetail.d.ts +12 -15
  159. package/dist/shared/wire/LambderCrashDetail.js +12 -15
  160. package/dist/shared/wire/LambderDefaultApiPath.d.ts +6 -0
  161. package/dist/shared/wire/LambderDefaultApiPath.js +6 -0
  162. package/dist/shared/wire/LambderHttpStatus.d.ts +6 -7
  163. package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +89 -0
  164. package/dist/shared/wire/LambderIdempotencyKeyScope.js +146 -0
  165. package/dist/shared/wire/LambderInvokeApiId.d.ts +27 -0
  166. package/dist/shared/wire/LambderInvokeApiId.js +27 -0
  167. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +79 -0
  168. package/dist/shared/wire/LambderOutcomeAssertions.js +112 -0
  169. package/dist/shared/wire/LambderRequestPayload.d.ts +18 -20
  170. package/dist/shared/wire/LambderRequestPayload.js +4 -6
  171. package/dist/stores/LambderCacheFiller.d.ts +48 -0
  172. package/dist/stores/LambderCacheFiller.js +119 -0
  173. package/dist/stores/LambderCacheKeys.d.ts +26 -0
  174. package/dist/stores/LambderCacheKeys.js +54 -0
  175. package/dist/stores/LambderCacheValues.d.ts +45 -0
  176. package/dist/stores/LambderCacheValues.js +74 -0
  177. package/dist/stores/LambderDdbCache.d.ts +121 -56
  178. package/dist/stores/LambderDdbCache.js +528 -225
  179. package/dist/stores/LambderDdbIdempotencyStore.d.ts +33 -22
  180. package/dist/stores/LambderDdbIdempotencyStore.js +75 -50
  181. package/dist/stores/LambderDdbRateLimiter.d.ts +76 -20
  182. package/dist/stores/LambderDdbRateLimiter.js +151 -39
  183. package/dist/stores/LambderDdbSdk.d.ts +43 -31
  184. package/dist/stores/LambderDdbSdk.js +79 -33
  185. package/dist/stores/LambderDdbSessionStore.d.ts +27 -14
  186. package/dist/stores/LambderDdbSessionStore.js +119 -47
  187. package/dist/stores/LambderHttpFileSource.d.ts +15 -6
  188. package/dist/stores/LambderHttpFileSource.js +15 -13
  189. package/dist/stores/LambderMemoryCache.d.ts +49 -0
  190. package/dist/stores/LambderMemoryCache.js +113 -0
  191. package/dist/stores/LambderMemoryIdempotencyStore.d.ts +13 -12
  192. package/dist/stores/LambderMemoryIdempotencyStore.js +31 -30
  193. package/dist/stores/LambderMemoryRateLimiter.d.ts +8 -9
  194. package/dist/stores/LambderMemoryRateLimiter.js +14 -13
  195. package/dist/stores/LambderMemorySessionStore.d.ts +14 -11
  196. package/dist/stores/LambderMemorySessionStore.js +38 -19
  197. package/dist/stores/LambderS3FileSource.d.ts +21 -6
  198. package/dist/stores/LambderS3FileSource.js +12 -7
  199. package/dist/testing/LambderTestApp.d.ts +176 -0
  200. package/dist/testing/LambderTestApp.js +204 -0
  201. package/dist/testing/LambderTestVisitor.d.ts +153 -0
  202. package/dist/testing/LambderTestVisitor.js +154 -0
  203. package/dist/testing.d.ts +27 -0
  204. package/dist/testing.js +24 -0
  205. package/package.json +20 -3
  206. package/dist/api/LambderApiPolicyEngine.d.ts +0 -36
  207. package/dist/api/LambderApiPolicyEngine.js +0 -77
  208. package/dist/shared/util/LambderKeyFields.d.ts +0 -32
  209. 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
- // The three header methods are the core's own header helpers over this
33
- // response's map: the case-insensitive lookup, the replace-under-any-casing
34
- // and the append-under-the-existing-casing rules are one implementation,
35
- // not a copy per class, so an answer and a response can never disagree
36
- // about what setting a header means.
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 exists so `res.status(...)`
71
- * offers the codes an app writes and catches the typo'd one. An answer is
72
- * plain data that already left that surface (a replay the idempotency store
73
- * persisted, a mock's answer, a third adapter's), so its status is a number
74
- * and a code outside the union is not a reason to refuse a request the app
75
- * has already answered. Stated once here rather than as a bare cast at the
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 isCompressibleContentType = (contentType) => {
89
- if (!contentType)
90
- return false;
91
- const mime = (contentType.split(";")[0] ?? "").trim().toLowerCase();
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 instead of this drop-list would quietly make a
141
- * revalidation the one exit of the request where the call's headers do not
142
- * belong to the call: a cacheable GET that also slides a session cookie would
143
- * stop refreshing it the moment the browser held the ETag, and a cross-origin
144
- * revalidation would lose Access-Control-Allow-Origin, so the browser would
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; hand-writing it there left
152
- * the v1/v2 split in four places.
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(headers)) {
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: headers, body, isBase64Encoded };
231
+ return { statusCode, multiValueHeaders: sentHeaders, body, isBase64Encoded };
169
232
  };
170
233
  /**
171
234
  * Convert an intermediate LambderResponse into the final Lambda response:
172
- * gzip negotiation (Accept-Encoding), ETag + If-None-Match 304, base64
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 meant a second
179
- // implementation of it lived here for the two headers this reads.
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
- let bodyBuffer = Buffer.isBuffer(response.body)
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
- options.compression !== null &&
201
- bodyBuffer.length >= options.compression.minBytes &&
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
- const encoding = settings.encodings.find((candidate) => acceptsEncoding(acceptEncoding, candidate));
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
- if (Buffer.isBuffer(response.body) || response.getHeader("Content-Encoding")) {
218
- outBody = bytesToBase64(bodyBuffer);
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
- outBody = bodyBuffer.toString("utf8");
223
- }
224
- }
225
- const etagEnabled = response.etag === true || (response.etag === "auto" &&
226
- options.etag &&
227
- response.statusCode === 200 &&
228
- (method === "GET" || method === "HEAD"));
229
- if (etagEnabled) {
230
- const crypto = await getCrypto();
231
- if (crypto) {
232
- const etagValue = `"${crypto.createHash("sha256").update(outBody).digest("hex").slice(0, 32)}"`;
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 plain UTF-8 one is not, and counting its UTF-16 code
250
- // units under-reported a non-ASCII response by up to 3x, which is the one
251
- // way this guard could pass a body Lambda then refuses with an opaque
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 is what lets a typed caller (LambderInvokeCaller.api) promise it.
26
- * Untyped resolvers (`TOutput = any`) accept anything, as before. This is
27
- * the resolver's method type; the core's answer type is LambderApiAnswer.
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
- constructor({ files, apiVersion, ctx }: {
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
- constructor({ files, apiVersion, ctx }) {
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
- response.setHeader("Location", url);
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
- * The three places that gate on a method (a route matcher's `method`,
28
- * servePublicFiles and serveIndexHtml) share this one rule, so neighbouring
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
- const matchFn = pathToRegexpMatch(path, { decode: decodeURIComponent });
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
- * The three places that gate on a method (a route matcher's `method`,
36
- * servePublicFiles and serveIndexHtml) share this one rule, so neighbouring
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();