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.
Files changed (195) hide show
  1. package/CHANGELOG.md +2316 -0
  2. package/README.md +60 -33
  3. package/dist/api/LambderApiAnswer.d.ts +40 -0
  4. package/dist/api/LambderApiAnswer.js +19 -0
  5. package/dist/api/LambderApiCallContext.d.ts +38 -0
  6. package/dist/api/LambderApiCallContext.js +13 -0
  7. package/dist/api/LambderApiDefinition.d.ts +18 -0
  8. package/dist/api/LambderApiDefinition.js +1 -0
  9. package/dist/api/LambderApiEnvelope.d.ts +67 -0
  10. package/dist/api/LambderApiEnvelope.js +180 -0
  11. package/dist/api/LambderApiGuards.d.ts +302 -0
  12. package/dist/api/LambderApiGuards.js +134 -0
  13. package/dist/api/LambderApiIdempotency.d.ts +122 -0
  14. package/dist/api/LambderApiIdempotency.js +330 -0
  15. package/dist/api/LambderApiPipeline.d.ts +134 -0
  16. package/dist/api/LambderApiPipeline.js +221 -0
  17. package/dist/api/LambderApiPolicyEngine.d.ts +36 -0
  18. package/dist/api/LambderApiPolicyEngine.js +77 -0
  19. package/dist/api/LambderApiRateLimits.d.ts +206 -0
  20. package/dist/api/LambderApiRateLimits.js +239 -0
  21. package/dist/api/LambderApiRequest.d.ts +101 -0
  22. package/dist/api/LambderApiRequest.js +129 -0
  23. package/dist/api/LambderApiValidationRefusal.d.ts +32 -0
  24. package/dist/api/LambderApiValidationRefusal.js +40 -0
  25. package/dist/client/LambderCaller.d.ts +62 -55
  26. package/dist/client/LambderCaller.js +147 -90
  27. package/dist/client/lambderFetchTransport.d.ts +9 -0
  28. package/dist/client/lambderFetchTransport.js +71 -0
  29. package/dist/client.d.ts +20 -10
  30. package/dist/client.js +11 -5
  31. package/dist/core/Lambder.d.ts +117 -253
  32. package/dist/core/Lambder.js +374 -341
  33. package/dist/core/LambderContext.d.ts +54 -44
  34. package/dist/core/LambderContext.js +41 -110
  35. package/dist/core/LambderCreateOptions.d.ts +285 -0
  36. package/dist/core/LambderCreateOptions.js +44 -0
  37. package/dist/core/LambderFiles.d.ts +1 -45
  38. package/dist/core/LambderFiles.js +18 -38
  39. package/dist/core/LambderIndexHtml.d.ts +37 -0
  40. package/dist/core/LambderIndexHtml.js +87 -0
  41. package/dist/core/LambderPolicyBuilders.d.ts +17 -0
  42. package/dist/core/LambderPolicyBuilders.js +16 -0
  43. package/dist/core/LambderPublicFiles.d.ts +5 -2
  44. package/dist/core/LambderPublicFiles.js +7 -2
  45. package/dist/core/LambderResolver.d.ts +8 -6
  46. package/dist/core/LambderResponse.d.ts +29 -11
  47. package/dist/core/LambderResponse.js +96 -49
  48. package/dist/core/LambderResponseBuilder.d.ts +18 -14
  49. package/dist/core/LambderResponseBuilder.js +19 -25
  50. package/dist/core/LambderRouting.d.ts +18 -7
  51. package/dist/core/LambderRouting.js +17 -7
  52. package/dist/core/LambderTemplatingEngine.d.ts +0 -62
  53. package/dist/core/LambderTemplatingEngine.js +7 -3
  54. package/dist/index.d.ts +85 -32
  55. package/dist/index.js +44 -16
  56. package/dist/invoke/LambderInvokeCaller.d.ts +46 -139
  57. package/dist/invoke/LambderInvokeCaller.js +140 -335
  58. package/dist/invoke/LambderInvokeOutcome.d.ts +165 -0
  59. package/dist/invoke/LambderInvokeOutcome.js +129 -0
  60. package/dist/invoke/LambderLambdaEvent.d.ts +81 -0
  61. package/dist/invoke/LambderLambdaEvent.js +187 -0
  62. package/dist/invoke/lambderHandlerTransport.d.ts +36 -0
  63. package/dist/invoke/lambderHandlerTransport.js +89 -0
  64. package/dist/mock/LambderMockApp.d.ts +352 -0
  65. package/dist/mock/LambderMockApp.js +815 -0
  66. package/dist/mock/LambderMockBrowserCookies.d.ts +55 -0
  67. package/dist/mock/LambderMockBrowserCookies.js +76 -0
  68. package/dist/mock/LambderMockCallRecorder.d.ts +85 -0
  69. package/dist/mock/LambderMockCallRecorder.js +183 -0
  70. package/dist/mock/LambderMockCreateOptions.d.ts +161 -0
  71. package/dist/mock/LambderMockCreateOptions.js +9 -0
  72. package/dist/mock/LambderMockEntryRegistry.d.ts +52 -0
  73. package/dist/mock/LambderMockEntryRegistry.js +126 -0
  74. package/dist/mock/LambderMockFailureInjector.d.ts +60 -0
  75. package/dist/mock/LambderMockFailureInjector.js +138 -0
  76. package/dist/mock/LambderMockTypes.d.ts +421 -0
  77. package/dist/mock/LambderMockTypes.js +8 -0
  78. package/dist/mock/lambderMockConsoleLogger.d.ts +16 -0
  79. package/dist/mock/lambderMockConsoleLogger.js +35 -0
  80. package/dist/mock/lambderMockInvokeTransport.d.ts +50 -0
  81. package/dist/mock/lambderMockInvokeTransport.js +52 -0
  82. package/dist/mock/lambderMockMswHandler.d.ts +99 -0
  83. package/dist/mock/lambderMockMswHandler.js +126 -0
  84. package/dist/mock.d.ts +34 -0
  85. package/dist/mock.js +27 -0
  86. package/dist/session/LambderSessionController.d.ts +199 -30
  87. package/dist/session/LambderSessionController.js +396 -82
  88. package/dist/session/LambderSessionCrypto.d.ts +66 -0
  89. package/dist/session/LambderSessionCrypto.js +101 -0
  90. package/dist/session/LambderSessionManager.d.ts +118 -80
  91. package/dist/session/LambderSessionManager.js +212 -184
  92. package/dist/shared/LambderI18n.d.ts +6 -6
  93. package/dist/shared/LambderI18n.js +1 -1
  94. package/dist/shared/contracts/LambderFileSource.d.ts +33 -0
  95. package/dist/shared/contracts/LambderFileSource.js +19 -0
  96. package/dist/shared/contracts/LambderIdempotencyStore.d.ts +66 -0
  97. package/dist/shared/contracts/LambderIdempotencyStore.js +12 -0
  98. package/dist/shared/contracts/LambderRateLimiter.d.ts +71 -0
  99. package/dist/shared/contracts/LambderRateLimiter.js +24 -0
  100. package/dist/shared/contracts/LambderSessionStore.d.ts +72 -0
  101. package/dist/shared/contracts/LambderSessionStore.js +13 -0
  102. package/dist/shared/transport/LambderApiTransport.d.ts +139 -0
  103. package/dist/shared/transport/LambderApiTransport.js +65 -0
  104. package/dist/shared/transport/LambderCookieJar.d.ts +121 -0
  105. package/dist/shared/transport/LambderCookieJar.js +246 -0
  106. package/dist/shared/transport/lambderCookieJarTransport.d.ts +30 -0
  107. package/dist/shared/transport/lambderCookieJarTransport.js +60 -0
  108. package/dist/shared/util/LambderBase64.d.ts +10 -0
  109. package/dist/shared/util/LambderBase64.js +27 -0
  110. package/dist/shared/util/LambderCallAbort.d.ts +62 -0
  111. package/dist/shared/util/LambderCallAbort.js +80 -0
  112. package/dist/shared/util/LambderClientIp.d.ts +32 -0
  113. package/dist/shared/util/LambderClientIp.js +56 -0
  114. package/dist/shared/util/LambderExpiringMap.d.ts +119 -0
  115. package/dist/shared/util/LambderExpiringMap.js +217 -0
  116. package/dist/shared/util/LambderKeyFields.d.ts +32 -0
  117. package/dist/shared/util/LambderKeyFields.js +34 -0
  118. package/dist/shared/util/LambderNodeModules.d.ts +9 -0
  119. package/dist/shared/util/LambderNodeModules.js +39 -0
  120. package/dist/shared/util/LambderOptionChecks.d.ts +17 -0
  121. package/dist/shared/util/LambderOptionChecks.js +33 -0
  122. package/dist/shared/util/LambderResponseBrand.d.ts +20 -0
  123. package/dist/shared/util/LambderResponseBrand.js +18 -0
  124. package/dist/shared/util/LambderTextDigest.d.ts +17 -0
  125. package/dist/shared/util/LambderTextDigest.js +34 -0
  126. package/dist/shared/util/LambderTypeUtilities.d.ts +33 -0
  127. package/dist/shared/util/LambderTypeUtilities.js +8 -0
  128. package/dist/shared/wire/LambderAnswerHeaders.d.ts +60 -0
  129. package/dist/shared/wire/LambderAnswerHeaders.js +94 -0
  130. package/dist/shared/wire/LambderApiContract.d.ts +129 -0
  131. package/dist/shared/wire/LambderApiOptionValues.d.ts +39 -0
  132. package/dist/shared/wire/LambderApiOptionValues.js +11 -0
  133. package/dist/shared/wire/LambderApiOutcome.d.ts +128 -0
  134. package/dist/shared/{LambderApiOutcome.js → wire/LambderApiOutcome.js} +16 -9
  135. package/dist/shared/{LambderApiError.d.ts → wire/LambderApiRefusal.d.ts} +48 -26
  136. package/dist/shared/{LambderApiError.js → wire/LambderApiRefusal.js} +13 -11
  137. package/dist/shared/wire/LambderCallOptions.d.ts +171 -0
  138. package/dist/shared/wire/LambderCallOptions.js +17 -0
  139. package/dist/shared/{LambderCompressionCodec.d.ts → wire/LambderCompressionCodec.d.ts} +10 -6
  140. package/dist/shared/{LambderCompressionCodec.js → wire/LambderCompressionCodec.js} +67 -23
  141. package/dist/shared/{LambderCompressionOption.d.ts → wire/LambderCompressionOption.d.ts} +1 -1
  142. package/dist/shared/{LambderCompressionOption.js → wire/LambderCompressionOption.js} +3 -4
  143. package/dist/shared/{LambderCrashDetail.d.ts → wire/LambderCrashDetail.d.ts} +10 -0
  144. package/dist/shared/{LambderCrashDetail.js → wire/LambderCrashDetail.js} +30 -0
  145. package/dist/shared/wire/LambderHttpStatus.d.ts +12 -0
  146. package/dist/shared/wire/LambderHttpStatus.js +1 -0
  147. package/dist/shared/{LambderRequestPayload.d.ts → wire/LambderRequestPayload.d.ts} +25 -17
  148. package/dist/shared/{LambderRequestPayload.js → wire/LambderRequestPayload.js} +29 -52
  149. package/dist/shared/wire/LambderSessionCookieNames.d.ts +9 -0
  150. package/dist/shared/wire/LambderSessionCookieNames.js +9 -0
  151. package/dist/stores/LambderDdbCache.d.ts +12 -9
  152. package/dist/stores/LambderDdbCache.js +56 -47
  153. package/dist/stores/{LambderDdbIdempotency.d.ts → LambderDdbIdempotencyStore.d.ts} +41 -31
  154. package/dist/stores/LambderDdbIdempotencyStore.js +319 -0
  155. package/dist/stores/LambderDdbRateLimiter.d.ts +30 -49
  156. package/dist/stores/LambderDdbRateLimiter.js +47 -45
  157. package/dist/stores/LambderDdbSdk.d.ts +83 -6
  158. package/dist/stores/LambderDdbSdk.js +83 -2
  159. package/dist/stores/LambderDdbSessionStore.d.ts +65 -0
  160. package/dist/stores/LambderDdbSessionStore.js +161 -0
  161. package/dist/stores/LambderHttpFileSource.d.ts +1 -1
  162. package/dist/stores/LambderHttpFileSource.js +10 -1
  163. package/dist/stores/LambderLocalFileSource.d.ts +15 -0
  164. package/dist/stores/LambderLocalFileSource.js +28 -0
  165. package/dist/stores/LambderMemoryIdempotencyStore.d.ts +63 -0
  166. package/dist/stores/LambderMemoryIdempotencyStore.js +113 -0
  167. package/dist/stores/LambderMemoryRateLimiter.d.ts +34 -0
  168. package/dist/stores/LambderMemoryRateLimiter.js +64 -0
  169. package/dist/stores/LambderMemorySessionStore.d.ts +48 -0
  170. package/dist/stores/LambderMemorySessionStore.js +74 -0
  171. package/dist/stores/LambderS3FileSource.d.ts +1 -1
  172. package/dist/stores/LambderS3FileSource.js +1 -1
  173. package/package.json +21 -19
  174. package/dist/client/LambderMSW.d.ts +0 -69
  175. package/dist/client/LambderMSW.js +0 -121
  176. package/dist/policies/LambderApiGuards.d.ts +0 -256
  177. package/dist/policies/LambderApiGuards.js +0 -94
  178. package/dist/policies/LambderApiIdempotency.d.ts +0 -58
  179. package/dist/policies/LambderApiIdempotency.js +0 -219
  180. package/dist/policies/LambderApiPolicies.d.ts +0 -42
  181. package/dist/policies/LambderApiPolicies.js +0 -52
  182. package/dist/policies/LambderApiRateLimits.d.ts +0 -132
  183. package/dist/policies/LambderApiRateLimits.js +0 -119
  184. package/dist/shared/LambderApiContract.d.ts +0 -57
  185. package/dist/shared/LambderApiOutcome.d.ts +0 -69
  186. package/dist/shared/LambderCallOptions.d.ts +0 -71
  187. package/dist/shared/LambderCallOptions.js +0 -16
  188. package/dist/shared/node-polyfills.d.ts +0 -4
  189. package/dist/shared/node-polyfills.js +0 -58
  190. package/dist/stores/LambderDdbIdempotency.js +0 -229
  191. package/dist/testing.d.ts +0 -9
  192. package/dist/testing.js +0 -8
  193. /package/dist/shared/{LambderApiContract.js → wire/LambderApiContract.js} +0 -0
  194. /package/dist/{core → shared/wire}/LambderCookie.d.ts +0 -0
  195. /package/dist/{core → shared/wire}/LambderCookie.js +0 -0
@@ -1,6 +1,9 @@
1
- import { getCrypto } from "../shared/node-polyfills.js";
2
- import { compressText } from "../shared/LambderCompressionCodec.js";
3
- export const normalizeHeaders = (headers) => Object.fromEntries(Object.entries(headers ?? {}).map(([k, v]) => [k, Array.isArray(v) ? [...v] : [v]]));
1
+ import { LAMBDER_RESPONSE_BRAND } from "../shared/util/LambderResponseBrand.js";
2
+ import { bytesToBase64 } from "../shared/util/LambderBase64.js";
3
+ import { getCrypto } from "../shared/util/LambderNodeModules.js";
4
+ import { compressText } from "../shared/wire/LambderCompressionCodec.js";
5
+ import { getAnswerHeader, setAnswerHeader, addAnswerHeader } from "../shared/wire/LambderAnswerHeaders.js";
6
+ const normalizeHeaders = (headers) => Object.fromEntries(Object.entries(headers ?? {}).map(([k, v]) => [k, Array.isArray(v) ? [...v] : [v]]));
4
7
  /**
5
8
  * Intermediate response object returned by all response builder methods and by
6
9
  * route/api handlers. Bodies stay uncompressed and un-encoded so hooks can
@@ -11,6 +14,7 @@ export const normalizeHeaders = (headers) => Object.fromEntries(Object.entries(h
11
14
  * the request: the thrown response becomes the response.
12
15
  */
13
16
  export class LambderResponse {
17
+ [LAMBDER_RESPONSE_BRAND] = true;
14
18
  statusCode;
15
19
  headers;
16
20
  body;
@@ -25,36 +29,63 @@ export class LambderResponse {
25
29
  this.compress = init.compress ?? "auto";
26
30
  this.etag = init.etag ?? "auto";
27
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.
28
37
  getHeader(key) {
29
- const lower = key.toLowerCase();
30
- for (const [k, v] of Object.entries(this.headers)) {
31
- if (k.toLowerCase() === lower)
32
- return v;
33
- }
34
- return undefined;
38
+ return getAnswerHeader(this.headers, key);
35
39
  }
36
40
  setHeader(key, value) {
37
- const lower = key.toLowerCase();
38
- for (const k of Object.keys(this.headers)) {
39
- if (k.toLowerCase() === lower)
40
- delete this.headers[k];
41
- }
42
- this.headers[key] = Array.isArray(value) ? [...value] : [value];
41
+ setAnswerHeader(this.headers, key, value);
43
42
  return this;
44
43
  }
45
44
  addHeader(key, value) {
46
- const lower = key.toLowerCase();
47
- const existingKey = Object.keys(this.headers).find((k) => k.toLowerCase() === lower);
48
- if (existingKey) {
49
- this.headers[existingKey].push(value);
50
- }
51
- else {
52
- this.headers[key] = [value];
53
- }
45
+ addAnswerHeader(this.headers, key, value);
54
46
  return this;
55
47
  }
56
48
  }
57
- export const isCompressibleContentType = (contentType) => {
49
+ /**
50
+ * A handler's response as a core answer: what the API pipeline stores,
51
+ * replays and hands back. A Buffer body travels base64-encoded and marked
52
+ * as such, so the idempotency engine never caches it and finalization
53
+ * passes it through untouched; the compress and etag flags ride along so
54
+ * nothing a handler asked for is lost on the way through the core.
55
+ */
56
+ export const answerFromResponse = (response) => {
57
+ const binary = Buffer.isBuffer(response.body);
58
+ return {
59
+ statusCode: response.statusCode,
60
+ headers: normalizeHeaders(response.headers),
61
+ body: response.body === null ? "" : binary ? bytesToBase64(response.body) : String(response.body),
62
+ isBodyBase64: response.isBodyBase64 || binary,
63
+ compress: response.compress,
64
+ etag: response.etag,
65
+ };
66
+ };
67
+ /**
68
+ * An answer's status as the response model spells statuses.
69
+ *
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.
77
+ */
78
+ const httpStatusOfAnswer = (statusCode) => statusCode;
79
+ /** A core answer as the response hooks, CORS and finalization work on. */
80
+ export const responseFromAnswer = (answer) => new LambderResponse({
81
+ statusCode: httpStatusOfAnswer(answer.statusCode),
82
+ headers: answer.headers,
83
+ body: answer.body,
84
+ isBodyBase64: answer.isBodyBase64 ?? false,
85
+ compress: answer.compress ?? "auto",
86
+ etag: answer.etag ?? "auto",
87
+ });
88
+ const isCompressibleContentType = (contentType) => {
58
89
  if (!contentType)
59
90
  return false;
60
91
  const mime = (contentType.split(";")[0] ?? "").trim().toLowerCase();
@@ -72,7 +103,7 @@ export const isCompressibleContentType = (contentType) => {
72
103
  "application/lambder-json-stream",
73
104
  ].includes(mime);
74
105
  };
75
- export const acceptsEncoding = (acceptEncoding, encoding) => {
106
+ const acceptsEncoding = (acceptEncoding, encoding) => {
76
107
  if (!acceptEncoding)
77
108
  return false;
78
109
  return acceptEncoding.split(",").some((part) => {
@@ -102,18 +133,25 @@ export const DEFAULT_FINALIZE_OPTIONS = {
102
133
  etag: true,
103
134
  maxResponseBytes: 5_500_000,
104
135
  };
105
- const getRequestHeader = (ctx, name) => {
106
- if (!ctx?.headers)
107
- return undefined;
108
- const lower = name.toLowerCase();
109
- for (const [k, v] of Object.entries(ctx.headers)) {
110
- if (k.toLowerCase() === lower)
111
- return v ?? undefined;
112
- }
113
- return undefined;
114
- };
115
- /** Emit the format-specific Lambda response shape. */
116
- const emitResponse = (format, statusCode, headers, body, isBase64Encoded) => {
136
+ /**
137
+ * The headers a 304 leaves behind: they describe a body, and a 304 carries
138
+ * none. Everything else goes with it.
139
+ *
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.
146
+ */
147
+ const HEADERS_DROPPED_ON_NOT_MODIFIED = ["content-type", "content-length", "content-encoding"];
148
+ /**
149
+ * Emit the format-specific Lambda response shape. Exported because the
150
+ * 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.
153
+ */
154
+ export const emitResponse = (format, statusCode, headers, body, isBase64Encoded) => {
117
155
  if (format === "v2") {
118
156
  // Payload v2 has no multiValueHeaders: multi-values are comma-joined,
119
157
  // except Set-Cookie which uses the dedicated cookies array.
@@ -135,7 +173,11 @@ const emitResponse = (format, statusCode, headers, body, isBase64Encoded) => {
135
173
  * encoding, HEAD body stripping, and Lambda payload size guard. Emits the v1
136
174
  * (REST API) or v2 (HTTP API / Function URL) response shape.
137
175
  */
138
- export const finalizeResponse = async (ctx, response, options, format = "v1") => {
176
+ export const finalizeResponse = async (
177
+ // 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.
180
+ ctx, response, options, format = "v1") => {
139
181
  const method = (ctx?.method ?? "GET").toUpperCase();
140
182
  if (response.body === null) {
141
183
  return emitResponse(format, response.statusCode, response.headers, "", false);
@@ -164,7 +206,7 @@ export const finalizeResponse = async (ctx, response, options, format = "v1") =>
164
206
  // compress: true forces compression even with it globally off, so
165
207
  // the settings fall back to the defaults rather than being absent.
166
208
  const settings = options.compression ?? DEFAULT_RESPONSE_COMPRESSION_SETTINGS;
167
- const acceptEncoding = getRequestHeader(ctx, "accept-encoding");
209
+ const acceptEncoding = ctx?.header("accept-encoding");
168
210
  const encoding = settings.encodings.find((candidate) => acceptsEncoding(acceptEncoding, candidate));
169
211
  if (encoding) {
170
212
  // The same codec, quality and TEXT mode a stored record gets.
@@ -173,7 +215,7 @@ export const finalizeResponse = async (ctx, response, options, format = "v1") =>
173
215
  }
174
216
  }
175
217
  if (Buffer.isBuffer(response.body) || response.getHeader("Content-Encoding")) {
176
- outBody = bodyBuffer.toString("base64");
218
+ outBody = bytesToBase64(bodyBuffer);
177
219
  isBase64 = true;
178
220
  }
179
221
  else {
@@ -189,23 +231,28 @@ export const finalizeResponse = async (ctx, response, options, format = "v1") =>
189
231
  if (crypto) {
190
232
  const etagValue = `"${crypto.createHash("sha256").update(outBody).digest("hex").slice(0, 32)}"`;
191
233
  response.setHeader("ETag", etagValue);
192
- const ifNoneMatch = getRequestHeader(ctx, "if-none-match");
234
+ const ifNoneMatch = ctx?.header("if-none-match");
193
235
  if (ifNoneMatch && ifNoneMatch.split(",").map((s) => s.trim()).includes(etagValue)) {
194
- const preservedHeaders = {};
195
- for (const key of ["ETag", "Cache-Control", "Vary", "Expires", "Last-Modified"]) {
196
- const value = response.getHeader(key);
197
- if (value)
198
- preservedHeaders[key] = value;
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;
199
240
  }
200
- return emitResponse(format, 304, preservedHeaders, "", false);
241
+ return emitResponse(format, 304, notModifiedHeaders, "", false);
201
242
  }
202
243
  }
203
244
  }
204
245
  if (method === "HEAD") {
205
246
  return emitResponse(format, response.statusCode, response.headers, "", false);
206
247
  }
207
- if (outBody.length > options.maxResponseBytes) {
208
- throw new Error(`Lambder: final response body is ${outBody.length} bytes which exceeds the configured ` +
248
+ // 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.
253
+ const outBytes = isBase64 ? outBody.length : Buffer.byteLength(outBody, "utf8");
254
+ if (outBytes > options.maxResponseBytes) {
255
+ throw new Error(`Lambder: final response body is ${outBytes} bytes which exceeds the configured ` +
209
256
  `maxResponseBytes (${options.maxResponseBytes}). Lambda caps proxy responses at ~6MB. ` +
210
257
  `Consider pagination or enabling compression.`);
211
258
  }
@@ -1,13 +1,14 @@
1
1
  import type { LambderRenderContext } from "./LambderContext.js";
2
- import { type LambderCookieOptions, type LambderClearCookieOptions } from "./LambderCookie.js";
2
+ import { type LambderCookieOptions, type LambderClearCookieOptions } from "../shared/wire/LambderCookie.js";
3
3
  import type { LambderFiles } from "./LambderFiles.js";
4
- import { LambderResponse, type HttpStatusCode, type LambderHeadersInput } from "./LambderResponse.js";
4
+ import { LambderResponse, type LambderHeadersInput } from "./LambderResponse.js";
5
+ import type { LambderHttpStatusCode } from "../shared/wire/LambderHttpStatus.js";
5
6
  import { LambderSafeHtml } from "../shared/LambderHtml.js";
6
7
  import type { LambderTemplateData } from "./LambderTemplatingEngine.js";
7
- import type { LambderApiResponseConfig } from "../shared/LambderApiContract.js";
8
- export type { LambderApiResponse, LambderApiResponseConfig } from "../shared/LambderApiContract.js";
8
+ import type { LambderApiResponseConfig, LambderApiNullAnswerConfig } from "../shared/wire/LambderApiContract.js";
9
+ export type { LambderApiEnvelopeBody, LambderApiResponseConfig } from "../shared/wire/LambderApiContract.js";
9
10
  export type LambderResponseOptions = {
10
- statusCode?: HttpStatusCode;
11
+ statusCode?: LambderHttpStatusCode;
11
12
  headers?: LambderHeadersInput;
12
13
  /** Shorthand for the Cache-Control header. */
13
14
  cacheControl?: string;
@@ -22,14 +23,15 @@ export type LambderResponseOptions = {
22
23
  * `message`). A bare `res.api(null)` compiles only when the output type
23
24
  * itself allows null, so a success payload is always the declared output,
24
25
  * which is what lets a typed caller (LambderInvokeCaller.api) promise it.
25
- * Untyped resolvers (`TOutput = any`) accept anything, as before.
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
28
  */
27
- export type LambderApiAnswer<TOutput, TResult> = {
29
+ export type LambderResolverApiMethod<TOutput, TResult> = {
28
30
  (payload: TOutput, config?: LambderApiResponseConfig, options?: LambderResponseOptions): TResult;
29
- (payload: null, config: LambderApiResponseConfig, options?: LambderResponseOptions): TResult;
31
+ (payload: null, config: LambderApiNullAnswerConfig, options?: LambderResponseOptions): TResult;
30
32
  };
31
33
  export type LambderRawResponseInit = {
32
- statusCode: HttpStatusCode;
34
+ statusCode: LambderHttpStatusCode;
33
35
  headers?: LambderHeadersInput;
34
36
  body: string | Buffer | null;
35
37
  /** True when body is already a base64-encoded string. */
@@ -49,7 +51,9 @@ export default class LambderResponseBuilder<TResponse = any> {
49
51
  private buildResponse;
50
52
  /** The instance's file reader, which res.file and res.templateFile need. */
51
53
  private requireFiles;
54
+ /** Appends a response header; applied onto the response once the handler has one, in call order. */
52
55
  addHeader(key: string, value: string): void;
56
+ /** Replaces a response header; applied onto the response once the handler has one, in call order. */
53
57
  setHeader(key: string, value: string | string[]): void;
54
58
  /**
55
59
  * Adds a Set-Cookie header. A function-form `domain` is resolved against
@@ -64,15 +68,15 @@ export default class LambderResponseBuilder<TResponse = any> {
64
68
  * different cookie and deletes nothing.
65
69
  */
66
70
  clearCookie(name: string, options?: LambderClearCookieOptions): void;
67
- logToApiResponse(input: any): void;
71
+ logToApiResponse(input: unknown): void;
68
72
  raw(init: LambderRawResponseInit): LambderResponse;
69
73
  json(data: Record<string, any>, options?: LambderResponseOptions): LambderResponse;
70
74
  text(data: string, options?: LambderResponseOptions): LambderResponse;
71
75
  xml(data: string | LambderSafeHtml, options?: LambderResponseOptions): LambderResponse;
72
76
  html(data: string | LambderSafeHtml, options?: LambderResponseOptions): LambderResponse;
73
- status(statusCode: HttpStatusCode, body?: string, options?: LambderResponseOptions): LambderResponse;
77
+ status(statusCode: LambderHttpStatusCode, body?: string, options?: LambderResponseOptions): LambderResponse;
74
78
  status404(data: string, options?: LambderResponseOptions): LambderResponse;
75
- redirect(url: string, statusCode?: HttpStatusCode, options?: LambderResponseOptions): LambderResponse;
79
+ redirect(url: string, statusCode?: LambderHttpStatusCode, options?: LambderResponseOptions): LambderResponse;
76
80
  versionExpired(options?: LambderResponseOptions): LambderResponse;
77
81
  fileBase64(fileBase64: string, mimeType: string, options?: LambderResponseOptions): LambderResponse;
78
82
  /** A file from the files source as a response; 404 when there is none. */
@@ -89,8 +93,8 @@ export default class LambderResponseBuilder<TResponse = any> {
89
93
  htmlVirtualSlots?: boolean;
90
94
  }): Promise<LambderResponse>;
91
95
  api(payload: TResponse, config?: LambderApiResponseConfig, options?: LambderResponseOptions): LambderResponse;
92
- api(payload: null, config: LambderApiResponseConfig, options?: LambderResponseOptions): LambderResponse;
96
+ api(payload: null, config: LambderApiNullAnswerConfig, options?: LambderResponseOptions): LambderResponse;
93
97
  /** Same as api() but forces compression of the response body. */
94
98
  apiBinary(payload: TResponse, config?: LambderApiResponseConfig, options?: LambderResponseOptions): LambderResponse;
95
- apiBinary(payload: null, config: LambderApiResponseConfig, options?: LambderResponseOptions): LambderResponse;
99
+ apiBinary(payload: null, config: LambderApiNullAnswerConfig, options?: LambderResponseOptions): LambderResponse;
96
100
  }
@@ -1,5 +1,6 @@
1
- import { serializeCookie, serializeClearCookie } from "./LambderCookie.js";
1
+ import { serializeCookie, serializeClearCookie } from "../shared/wire/LambderCookie.js";
2
2
  import { LambderResponse } from "./LambderResponse.js";
3
+ import { buildApiEnvelope } from "../api/LambderApiEnvelope.js";
3
4
  export default class LambderResponseBuilder {
4
5
  files;
5
6
  apiVersion;
@@ -29,21 +30,21 @@ export default class LambderResponseBuilder {
29
30
  /** The instance's file reader, which res.file and res.templateFile need. */
30
31
  requireFiles(method) {
31
32
  if (!this.files)
32
- throw new Error(`${method} requires the files option at creation (e.g. files: new LambderLocalFileSource({ root }))`);
33
+ throw new Error(`Lambder: ${method} requires the files option at creation (e.g. files: new LambderLocalFileSource({ root }))`);
33
34
  return this.files;
34
35
  }
36
+ /** Appends a response header; applied onto the response once the handler has one, in call order. */
35
37
  addHeader(key, value) {
36
38
  if (!this.ctx)
37
- throw new Error(".addHeader function is not available within this hook");
38
- this.ctx._otherInternal.addHeaderFnAccumulator.push({ key, value });
39
+ throw new Error("Lambder: res.addHeader needs the request context, and this response builder was created without one.");
40
+ this.ctx.responseHeaders.add(key, value);
39
41
  }
40
42
  ;
43
+ /** Replaces a response header; applied onto the response once the handler has one, in call order. */
41
44
  setHeader(key, value) {
42
45
  if (!this.ctx)
43
- throw new Error(".setHeader function is not available within this hook");
44
- this.ctx._otherInternal.addHeaderFnAccumulator = this.ctx._otherInternal.addHeaderFnAccumulator
45
- .filter((header) => header.key !== key);
46
- this.ctx._otherInternal.setHeaderFnAccumulator.push({ key, value });
46
+ throw new Error("Lambder: res.setHeader needs the request context, and this response builder was created without one.");
47
+ this.ctx.responseHeaders.set(key, value);
47
48
  }
48
49
  ;
49
50
  /**
@@ -53,7 +54,7 @@ export default class LambderResponseBuilder {
53
54
  */
54
55
  setCookie(name, value, options) {
55
56
  if (!this.ctx)
56
- throw new Error(".setCookie function is not available within this hook");
57
+ throw new Error("Lambder: res.setCookie needs the request context, and this response builder was created without one.");
57
58
  this.addHeader("Set-Cookie", serializeCookie(name, value, options, this.ctx.host));
58
59
  }
59
60
  ;
@@ -65,14 +66,14 @@ export default class LambderResponseBuilder {
65
66
  */
66
67
  clearCookie(name, options) {
67
68
  if (!this.ctx)
68
- throw new Error(".clearCookie function is not available within this hook");
69
+ throw new Error("Lambder: res.clearCookie needs the request context, and this response builder was created without one.");
69
70
  this.addHeader("Set-Cookie", serializeClearCookie(name, options, this.ctx.host));
70
71
  }
71
72
  ;
72
73
  logToApiResponse(input) {
73
74
  if (!this.ctx)
74
- throw new Error(".logToApiResponse function is not available within this hook");
75
- this.ctx._otherInternal.logToApiResponseAccumulator.push(input);
75
+ throw new Error("Lambder: res.logToApiResponse needs the request context, and this response builder was created without one.");
76
+ this.ctx.logList.push(input);
76
77
  }
77
78
  ;
78
79
  raw(init) {
@@ -157,19 +158,12 @@ export default class LambderResponseBuilder {
157
158
  return this.buildResponse(200, "text/html; charset=utf-8", template.render(data), options);
158
159
  }
159
160
  ;
160
- api(payload, { versionExpired, sessionExpired, notAuthorized, message, errorMessage, logList, crash, } = {}, options) {
161
- const finalLogList = logList || this.ctx?._otherInternal?.logToApiResponseAccumulator;
162
- return this.json({
163
- apiVersion: this.apiVersion,
164
- payload,
165
- ...(versionExpired ? { versionExpired } : {}),
166
- ...(sessionExpired ? { sessionExpired } : {}),
167
- ...(notAuthorized ? { notAuthorized } : {}),
168
- ...(message ? { message } : {}),
169
- ...(errorMessage ? { errorMessage } : {}),
170
- ...(crash ? { crash } : {}),
171
- ...(finalLogList?.length ? { logList: finalLogList } : {}),
172
- }, options);
161
+ api(payload, config = {}, options) {
162
+ // The envelope is the core's (one writer for both the server and the
163
+ // mock runtime); the logList channel is what this request accumulated
164
+ // unless the config names its own.
165
+ const envelope = buildApiEnvelope(this.apiVersion, payload, { ...config, logList: config.logList || this.ctx?.logList });
166
+ return this.json(envelope, options);
173
167
  }
174
168
  ;
175
169
  apiBinary(payload, config = {}, options) {
@@ -1,23 +1,34 @@
1
1
  import type { LambderRenderContext } from "./LambderContext.js";
2
- type Path = `/${string}`;
2
+ /** A route path as an app writes it: absolute, so a matcher and the index-HTML layer agree on the shape. */
3
+ export type LambderRoutePath = `/${string}`;
3
4
  type CutAt<S extends string, D extends string> = S extends `${infer Head}${D}${string}` ? Head : S;
4
5
  type ParamNameFrom<S extends string> = CutAt<CutAt<CutAt<CutAt<CutAt<CutAt<CutAt<S, "/">, ".">, "(">, "?">, "+">, "*">, "-">;
5
6
  type PathParamNames<T extends string> = T extends `${string}:${infer Rest}` ? (ParamNameFrom<Rest> extends "" ? never : ParamNameFrom<Rest>) | PathParamNames<Rest> : never;
6
- export type PathParamsOf<T extends string> = string extends T ? Record<string, string> : T extends `${string}(${string}` ? Record<string, string> : [PathParamNames<T>] extends [never] ? Record<string, string> : {
7
+ export type LambderPathParamsOf<T extends string> = string extends T ? Record<string, string> : T extends `${string}(${string}` ? Record<string, string> : [PathParamNames<T>] extends [never] ? Record<string, string> : {
7
8
  [K in PathParamNames<T>]: string;
8
9
  };
9
- export type ConditionFunction = (ctx: LambderRenderContext) => boolean;
10
+ export type LambderRouteConditionFn = (ctx: LambderRenderContext) => boolean;
10
11
  /** Structured route matcher: all provided fields must match. */
11
12
  export type LambderRouteMatcher = {
12
- path?: Path | RegExp;
13
+ path?: LambderRoutePath | RegExp;
13
14
  host?: string | RegExp;
14
15
  /** One or more HTTP methods; HEAD requests also match GET routes. */
15
16
  method?: string | string[];
16
- condition?: ConditionFunction;
17
+ condition?: LambderRouteConditionFn;
17
18
  };
18
- export type RouteCondition = Path | RegExp | ConditionFunction | LambderRouteMatcher;
19
+ export type LambderRouteCondition = LambderRoutePath | RegExp | LambderRouteConditionFn | LambderRouteMatcher;
19
20
  /** Returns matched path params, or false when the route doesn't match. */
20
21
  export type CompiledMatcher = (ctx: LambderRenderContext) => false | Record<string, string>;
22
+ /**
23
+ * Whether a request method is one the slot accepts, with HEAD folded into GET
24
+ * unless the list names HEAD itself: a HEAD is a GET whose body finalization
25
+ * strips, so an app that narrowed a slot to ["GET"] did not mean to 404 it.
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.
30
+ */
31
+ export declare const allowsRequestMethod: (methods: ReadonlySet<string>, requestMethod: string) => boolean;
21
32
  /** Compile a route condition once at registration time. */
22
- export declare const compileRouteMatcher: (condition: RouteCondition) => CompiledMatcher;
33
+ export declare const compileRouteMatcher: (condition: LambderRouteCondition) => CompiledMatcher;
23
34
  export {};
@@ -27,6 +27,21 @@ const compilePathMatcher = (path) => {
27
27
  return params;
28
28
  };
29
29
  };
30
+ /**
31
+ * Whether a request method is one the slot accepts, with HEAD folded into GET
32
+ * unless the list names HEAD itself: a HEAD is a GET whose body finalization
33
+ * strips, so an app that narrowed a slot to ["GET"] did not mean to 404 it.
34
+ *
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.
38
+ */
39
+ export const allowsRequestMethod = (methods, requestMethod) => {
40
+ const method = requestMethod.toUpperCase();
41
+ if (methods.has(method))
42
+ return true;
43
+ return method === "HEAD" && methods.has("GET");
44
+ };
30
45
  /** Compile a route condition once at registration time. */
31
46
  export const compileRouteMatcher = (condition) => {
32
47
  if (typeof condition === "string" || condition instanceof RegExp) {
@@ -42,13 +57,8 @@ export const compileRouteMatcher = (condition) => {
42
57
  ? new Set((Array.isArray(matcher.method) ? matcher.method : [matcher.method]).map((m) => m.toUpperCase()))
43
58
  : null;
44
59
  return (ctx) => {
45
- if (methods) {
46
- let requestMethod = ctx.method.toUpperCase();
47
- if (requestMethod === "HEAD" && !methods.has("HEAD"))
48
- requestMethod = "GET";
49
- if (!methods.has(requestMethod))
50
- return false;
51
- }
60
+ if (methods && !allowsRequestMethod(methods, ctx.method))
61
+ return false;
52
62
  if (matcher.host !== undefined) {
53
63
  if (typeof matcher.host === "string") {
54
64
  if (ctx.host.toLowerCase() !== matcher.host.toLowerCase())
@@ -1,66 +1,4 @@
1
1
  import { type LambderHtmlValue } from "../shared/LambderHtml.js";
2
- /**
3
- * LambderTemplatingEngine: a comment-only HTML template engine.
4
- *
5
- * Fully standalone: it has no dependency on Lambder routing or file serving,
6
- * and can template any HTML: app shells, emails, error pages. res.templateFile
7
- * uses it internally to render HTML files from the files source per request.
8
- *
9
- * Every construct is an HTML comment. That is the whole point: templates
10
- * survive HTML build pipelines (e.g. Vite) untouched, and are invisible in the
11
- * browser during frontend development, where the default content between the
12
- * markers renders as-is.
13
- *
14
- * Syntax:
15
- *
16
- * <!--slot:name-->default content<!--/slot:name--> replaceable region;
17
- * default kept when the
18
- * data omits "name"
19
- * <!--slot:name/--> insert-only point
20
- * <!--if:name--> ... <!--else--> ... <!--/if:name--> conditional block,
21
- * shown when data.name
22
- * is truthy
23
- * <!--if:!name--> ... <!--/if:!name--> negated conditional
24
- *
25
- * Blocks nest freely (ifs in slots, slots in ifs). There are intentionally no
26
- * loops or inline expressions: dynamic lists are built server-side with the
27
- * html`...` tagged template and passed in as a slot value. Attribute-position
28
- * values (e.g. <html lang="...">) are handled with if/else around whole-tag
29
- * variants.
30
- *
31
- * Data is dynamically typed: one Record<string, LambderHtmlValue> shared by
32
- * slots and conditions.
33
- * - strings/numbers are HTML-escaped on insertion (XSS-safe by default)
34
- * - html`...` / raw() / jsonScript() values are inserted verbatim
35
- * - arrays are flattened; null/undefined/false render the slot default
36
- * - unknown data keys are ignored, so one data object can serve several
37
- * templates with different slots
38
- *
39
- * Templates are parsed once (construction throws on unclosed or mismatched
40
- * blocks with a descriptive message); render() is a cheap tree walk, safe to
41
- * call per request. Discovered names are exposed on `slotNames` and
42
- * `conditionNames` for runtime validation.
43
- *
44
- * @example
45
- * ```typescript
46
- * import { LambderTemplatingEngine, html } from "lambder";
47
- *
48
- * const template = new LambderTemplatingEngine(`
49
- * <title><!--slot:title-->My Site<!--/slot:title--></title>
50
- * <!--if:isBeta--><meta name="robots" content="noindex" /><!--/if:isBeta-->
51
- * <!--slot:head/-->
52
- * `);
53
- *
54
- * template.render({
55
- * title: userInput, // escaped
56
- * isBeta: stage === "beta",
57
- * head: html`<link rel="canonical" href="${canonical}" />`, // verbatim
58
- * });
59
- *
60
- * // Or load from disk (compile once, render many times):
61
- * const emailTemplate = await LambderTemplatingEngine.fromFile("./templates/welcome.html");
62
- * ```
63
- */
64
2
  export type LambderTemplateData = Record<string, LambderHtmlValue>;
65
3
  export type LambderTemplatingEngineOptions = {
66
4
  /**
@@ -1,4 +1,4 @@
1
- import { getFS } from "../shared/node-polyfills.js";
1
+ import { getFS } from "../shared/util/LambderNodeModules.js";
2
2
  import { renderHtmlValue } from "../shared/LambderHtml.js";
3
3
  const TOKEN_PATTERN = /<!--\s*(?:(slot:([\w-]+)\s*\/)|(slot:([\w-]+))|(\/slot:([\w-]+))|(if:(!?)([\w-]+))|(else)|(\/if:(!?)([\w-]+)))\s*-->/g;
4
4
  /**
@@ -92,7 +92,11 @@ const renderNodes = (nodes, data) => {
92
92
  out += value === undefined ? renderNodes(node.defaultNodes, data) : renderHtmlValue(value);
93
93
  }
94
94
  else {
95
- const condition = !!data[node.name] !== node.negated;
95
+ // Own properties only, the same lookup the slot branch uses:
96
+ // through the prototype, <!--if:toString--> was unconditionally
97
+ // true on every render.
98
+ const value = Object.prototype.hasOwnProperty.call(data, node.name) ? data[node.name] : undefined;
99
+ const condition = !!value !== node.negated;
96
100
  out += renderNodes(condition ? node.thenNodes : node.elseNodes, data);
97
101
  }
98
102
  }
@@ -130,7 +134,7 @@ export class LambderTemplatingEngine {
130
134
  static async fromFile(filePath, options = {}) {
131
135
  const fs = await getFS();
132
136
  if (!fs)
133
- throw new Error("LambderTemplatingEngine.fromFile requires a Node.js environment.");
137
+ throw new Error("Lambder: LambderTemplatingEngine.fromFile requires a Node.js environment.");
134
138
  const source = await fs.promises.readFile(filePath, "utf8");
135
139
  return new LambderTemplatingEngine(source, options);
136
140
  }