lambder 7.3.1 → 8.1.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 (237) hide show
  1. package/CHANGELOG.md +1047 -3
  2. package/README.md +46 -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 +68 -62
  13. package/dist/api/LambderApiIdempotency.js +214 -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 +47 -38
  17. package/dist/api/LambderApiPipeline.js +122 -63
  18. package/dist/api/LambderApiRateLimits.d.ts +201 -54
  19. package/dist/api/LambderApiRateLimits.js +185 -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/ContractTypePrinter.d.ts +85 -0
  27. package/dist/build/ContractTypePrinter.js +402 -0
  28. package/dist/build/freshProcessVerifier.d.ts +13 -0
  29. package/dist/build/freshProcessVerifier.js +19 -0
  30. package/dist/build/moduleLocation.d.ts +11 -0
  31. package/dist/build/moduleLocation.js +6 -0
  32. package/dist/build/writeApiContract.d.ts +78 -0
  33. package/dist/build/writeApiContract.js +302 -0
  34. package/dist/build/writeApiSignatures.d.ts +114 -0
  35. package/dist/build/writeApiSignatures.js +217 -0
  36. package/dist/build/writeFileAtomically.d.ts +8 -0
  37. package/dist/build/writeFileAtomically.js +22 -0
  38. package/dist/build.d.ts +14 -0
  39. package/dist/build.js +11 -0
  40. package/dist/client/LambderCaller.d.ts +13 -44
  41. package/dist/client/LambderCaller.js +77 -84
  42. package/dist/client/LambderReloadLoopBreaker.d.ts +56 -26
  43. package/dist/client/LambderReloadLoopBreaker.js +90 -46
  44. package/dist/client/LambderUploadRunner.d.ts +96 -0
  45. package/dist/client/LambderUploadRunner.js +234 -0
  46. package/dist/client/lambderFetchTransport.d.ts +4 -1
  47. package/dist/client/lambderFetchTransport.js +52 -28
  48. package/dist/client.d.ts +9 -3
  49. package/dist/client.js +6 -1
  50. package/dist/core/Lambder.d.ts +143 -79
  51. package/dist/core/Lambder.js +350 -231
  52. package/dist/core/LambderContext.d.ts +82 -15
  53. package/dist/core/LambderContext.js +107 -20
  54. package/dist/core/LambderCors.d.ts +21 -3
  55. package/dist/core/LambderCors.js +35 -16
  56. package/dist/core/LambderCrashHandling.d.ts +40 -0
  57. package/dist/core/LambderCrashHandling.js +97 -0
  58. package/dist/core/LambderCreateOptions.d.ts +151 -75
  59. package/dist/core/LambderCreateOptions.js +16 -23
  60. package/dist/core/LambderFiles.d.ts +21 -7
  61. package/dist/core/LambderFiles.js +62 -34
  62. package/dist/core/LambderIndexHtml.js +12 -11
  63. package/dist/core/LambderPolicyBuilders.d.ts +17 -5
  64. package/dist/core/LambderPolicyBuilders.js +17 -5
  65. package/dist/core/LambderPublicFiles.d.ts +11 -5
  66. package/dist/core/LambderPublicFiles.js +32 -4
  67. package/dist/core/LambderRequestPath.d.ts +43 -0
  68. package/dist/core/LambderRequestPath.js +63 -0
  69. package/dist/core/LambderResponse.d.ts +26 -5
  70. package/dist/core/LambderResponse.js +157 -70
  71. package/dist/core/LambderResponseBuilder.d.ts +49 -4
  72. package/dist/core/LambderResponseBuilder.js +64 -3
  73. package/dist/core/LambderRouting.d.ts +2 -3
  74. package/dist/core/LambderRouting.js +22 -7
  75. package/dist/core/LambderTemplatingEngine.js +211 -32
  76. package/dist/index.d.ts +25 -8
  77. package/dist/index.js +13 -4
  78. package/dist/invoke/LambderInvokeCaller.d.ts +37 -42
  79. package/dist/invoke/LambderInvokeCaller.js +76 -66
  80. package/dist/invoke/LambderInvokeOutcome.d.ts +27 -26
  81. package/dist/invoke/LambderInvokeOutcome.js +9 -22
  82. package/dist/invoke/LambderLambdaEvent.d.ts +29 -9
  83. package/dist/invoke/LambderLambdaEvent.js +40 -22
  84. package/dist/invoke/lambderHandlerTransport.d.ts +9 -10
  85. package/dist/invoke/lambderHandlerTransport.js +15 -18
  86. package/dist/mock/LambderMockApp.d.ts +67 -83
  87. package/dist/mock/LambderMockApp.js +167 -153
  88. package/dist/mock/LambderMockBrowserCookies.d.ts +24 -28
  89. package/dist/mock/LambderMockBrowserCookies.js +24 -28
  90. package/dist/mock/LambderMockCallRecorder.d.ts +15 -22
  91. package/dist/mock/LambderMockCallRecorder.js +19 -28
  92. package/dist/mock/LambderMockCreateOptions.d.ts +42 -24
  93. package/dist/mock/LambderMockEntryRegistry.d.ts +11 -12
  94. package/dist/mock/LambderMockEntryRegistry.js +24 -29
  95. package/dist/mock/LambderMockFailureInjector.d.ts +3 -6
  96. package/dist/mock/LambderMockFailureInjector.js +3 -6
  97. package/dist/mock/LambderMockTypes.d.ts +78 -108
  98. package/dist/mock/lambderMockInvokeTransport.d.ts +11 -13
  99. package/dist/mock/lambderMockInvokeTransport.js +11 -10
  100. package/dist/mock/lambderMockMswHandler.d.ts +43 -33
  101. package/dist/mock/lambderMockMswHandler.js +50 -39
  102. package/dist/mock/lambderMockUploadMswHandler.d.ts +26 -0
  103. package/dist/mock/lambderMockUploadMswHandler.js +28 -0
  104. package/dist/mock.d.ts +4 -1
  105. package/dist/mock.js +6 -3
  106. package/dist/session/LambderSessionController.d.ts +108 -89
  107. package/dist/session/LambderSessionController.js +187 -168
  108. package/dist/session/LambderSessionCrypto.d.ts +16 -7
  109. package/dist/session/LambderSessionCrypto.js +26 -12
  110. package/dist/session/LambderSessionManager.d.ts +124 -46
  111. package/dist/session/LambderSessionManager.js +262 -137
  112. package/dist/shared/LambderHtml.d.ts +42 -3
  113. package/dist/shared/LambderHtml.js +127 -7
  114. package/dist/shared/LambderHtmlPositions.d.ts +173 -0
  115. package/dist/shared/LambderHtmlPositions.js +652 -0
  116. package/dist/shared/LambderI18n.d.ts +10 -11
  117. package/dist/shared/LambderI18n.js +33 -21
  118. package/dist/shared/contracts/LambderCache.d.ts +66 -0
  119. package/dist/shared/contracts/LambderCache.js +11 -0
  120. package/dist/shared/contracts/LambderFileSource.d.ts +6 -6
  121. package/dist/shared/contracts/LambderFileSource.js +5 -8
  122. package/dist/shared/contracts/LambderIdempotencyStore.d.ts +51 -22
  123. package/dist/shared/contracts/LambderIdempotencyStore.js +4 -5
  124. package/dist/shared/contracts/LambderRateLimiter.d.ts +27 -15
  125. package/dist/shared/contracts/LambderRateLimiter.js +4 -5
  126. package/dist/shared/contracts/LambderSessionStore.d.ts +65 -26
  127. package/dist/shared/contracts/LambderSessionStore.js +5 -6
  128. package/dist/shared/contracts/LambderUploadBucket.d.ts +154 -0
  129. package/dist/shared/contracts/LambderUploadBucket.js +74 -0
  130. package/dist/shared/transport/LambderApiTransport.d.ts +27 -27
  131. package/dist/shared/transport/LambderApiTransport.js +7 -7
  132. package/dist/shared/transport/LambderCookieJar.d.ts +28 -35
  133. package/dist/shared/transport/LambderCookieJar.js +54 -66
  134. package/dist/shared/transport/lambderCookieJarTransport.d.ts +11 -13
  135. package/dist/shared/transport/lambderCookieJarTransport.js +24 -23
  136. package/dist/shared/util/LambderCallAbort.d.ts +5 -5
  137. package/dist/shared/util/LambderCallAbort.js +5 -5
  138. package/dist/shared/util/LambderClientIp.d.ts +27 -11
  139. package/dist/shared/util/LambderClientIp.js +96 -13
  140. package/dist/shared/util/LambderContentDisposition.d.ts +10 -0
  141. package/dist/shared/util/LambderContentDisposition.js +13 -0
  142. package/dist/shared/util/LambderExpiringMap.d.ts +35 -49
  143. package/dist/shared/util/LambderExpiringMap.js +41 -57
  144. package/dist/shared/util/LambderNodeModules.js +6 -7
  145. package/dist/shared/util/LambderOptionChecks.d.ts +4 -4
  146. package/dist/shared/util/LambderOptionChecks.js +4 -4
  147. package/dist/shared/util/LambderResponseBrand.d.ts +5 -5
  148. package/dist/shared/util/LambderResponseBrand.js +5 -5
  149. package/dist/shared/util/LambderTextDigest.d.ts +7 -5
  150. package/dist/shared/util/LambderTextDigest.js +11 -5
  151. package/dist/shared/util/LambderTypeUtilities.d.ts +7 -8
  152. package/dist/shared/util/LambderTypeUtilities.js +3 -3
  153. package/dist/shared/util/boundKeyField.d.ts +20 -0
  154. package/dist/shared/util/boundKeyField.js +34 -0
  155. package/dist/shared/util/canonicalJson.d.ts +11 -0
  156. package/dist/shared/util/canonicalJson.js +28 -0
  157. package/dist/shared/util/joinKeyFields.d.ts +20 -0
  158. package/dist/shared/util/joinKeyFields.js +22 -0
  159. package/dist/shared/wire/LambderAnswerHeaders.d.ts +12 -16
  160. package/dist/shared/wire/LambderAnswerHeaders.js +12 -16
  161. package/dist/shared/wire/LambderApiContract.d.ts +98 -53
  162. package/dist/shared/wire/LambderApiOutcome.d.ts +43 -31
  163. package/dist/shared/wire/LambderApiOutcome.js +48 -23
  164. package/dist/shared/wire/LambderApiRefusal.d.ts +45 -27
  165. package/dist/shared/wire/LambderApiRefusal.js +42 -7
  166. package/dist/shared/wire/LambderApiSignature.d.ts +18 -22
  167. package/dist/shared/wire/LambderApiSignature.js +16 -19
  168. package/dist/shared/wire/LambderCallOptions.d.ts +38 -47
  169. package/dist/shared/wire/LambderCallOptions.js +9 -11
  170. package/dist/shared/wire/LambderCompressionCodec.d.ts +29 -34
  171. package/dist/shared/wire/LambderCompressionCodec.js +31 -36
  172. package/dist/shared/wire/LambderCompressionOption.d.ts +9 -9
  173. package/dist/shared/wire/LambderCompressionOption.js +9 -9
  174. package/dist/shared/wire/LambderCrashDetail.d.ts +12 -15
  175. package/dist/shared/wire/LambderCrashDetail.js +12 -15
  176. package/dist/shared/wire/LambderDefaultApiPath.d.ts +6 -0
  177. package/dist/shared/wire/LambderDefaultApiPath.js +6 -0
  178. package/dist/shared/wire/LambderHttpStatus.d.ts +6 -7
  179. package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +89 -0
  180. package/dist/shared/wire/LambderIdempotencyKeyScope.js +146 -0
  181. package/dist/shared/wire/LambderInvokeApiId.d.ts +27 -0
  182. package/dist/shared/wire/LambderInvokeApiId.js +27 -0
  183. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +6 -7
  184. package/dist/shared/wire/LambderOutcomeAssertions.js +6 -7
  185. package/dist/shared/wire/LambderRequestPayload.d.ts +18 -20
  186. package/dist/shared/wire/LambderRequestPayload.js +4 -6
  187. package/dist/shared/wire/LambderUploadObjectFields.d.ts +10 -0
  188. package/dist/shared/wire/LambderUploadObjectFields.js +24 -0
  189. package/dist/shared/wire/LambderUploadRefusal.d.ts +9 -0
  190. package/dist/shared/wire/LambderUploadRefusal.js +18 -0
  191. package/dist/shared/wire/LambderUploadSchemas.d.ts +12 -0
  192. package/dist/shared/wire/LambderUploadSchemas.js +30 -0
  193. package/dist/stores/LambderCacheFiller.d.ts +48 -0
  194. package/dist/stores/LambderCacheFiller.js +119 -0
  195. package/dist/stores/LambderCacheKeys.d.ts +26 -0
  196. package/dist/stores/LambderCacheKeys.js +54 -0
  197. package/dist/stores/LambderCacheValues.d.ts +45 -0
  198. package/dist/stores/LambderCacheValues.js +74 -0
  199. package/dist/stores/LambderDdbCache.d.ts +121 -56
  200. package/dist/stores/LambderDdbCache.js +528 -225
  201. package/dist/stores/LambderDdbIdempotencyStore.d.ts +33 -22
  202. package/dist/stores/LambderDdbIdempotencyStore.js +75 -50
  203. package/dist/stores/LambderDdbRateLimiter.d.ts +76 -20
  204. package/dist/stores/LambderDdbRateLimiter.js +151 -39
  205. package/dist/stores/LambderDdbSdk.d.ts +43 -31
  206. package/dist/stores/LambderDdbSdk.js +80 -38
  207. package/dist/stores/LambderDdbSessionStore.d.ts +27 -14
  208. package/dist/stores/LambderDdbSessionStore.js +119 -47
  209. package/dist/stores/LambderHttpFileSource.d.ts +15 -6
  210. package/dist/stores/LambderHttpFileSource.js +15 -13
  211. package/dist/stores/LambderMemoryCache.d.ts +49 -0
  212. package/dist/stores/LambderMemoryCache.js +113 -0
  213. package/dist/stores/LambderMemoryIdempotencyStore.d.ts +13 -12
  214. package/dist/stores/LambderMemoryIdempotencyStore.js +31 -30
  215. package/dist/stores/LambderMemoryRateLimiter.d.ts +8 -9
  216. package/dist/stores/LambderMemoryRateLimiter.js +14 -13
  217. package/dist/stores/LambderMemorySessionStore.d.ts +14 -11
  218. package/dist/stores/LambderMemorySessionStore.js +38 -19
  219. package/dist/stores/LambderMemoryUploadBucket.d.ts +99 -0
  220. package/dist/stores/LambderMemoryUploadBucket.js +219 -0
  221. package/dist/stores/LambderS3FileSource.d.ts +21 -6
  222. package/dist/stores/LambderS3FileSource.js +12 -7
  223. package/dist/stores/LambderS3UploadBucket.d.ts +73 -0
  224. package/dist/stores/LambderS3UploadBucket.js +144 -0
  225. package/dist/stores/LambderSdkInstallHint.d.ts +11 -0
  226. package/dist/stores/LambderSdkInstallHint.js +14 -0
  227. package/dist/testing/LambderTestApp.d.ts +23 -25
  228. package/dist/testing/LambderTestApp.js +22 -24
  229. package/dist/testing/LambderTestVisitor.d.ts +10 -12
  230. package/dist/testing/LambderTestVisitor.js +15 -15
  231. package/dist/testing.d.ts +3 -0
  232. package/dist/testing.js +2 -0
  233. package/package.json +26 -3
  234. package/dist/api/LambderApiPolicyEngine.d.ts +0 -47
  235. package/dist/api/LambderApiPolicyEngine.js +0 -85
  236. package/dist/shared/util/LambderKeyFields.d.ts +0 -32
  237. package/dist/shared/util/LambderKeyFields.js +0 -34
@@ -2,9 +2,11 @@ import LambderResolver from "./LambderResolver.js";
2
2
  import LambderResponseBuilder from "./LambderResponseBuilder.js";
3
3
  import { LambderResponse, finalizeResponse, answerFromResponse, responseFromAnswer, emitResponse, DEFAULT_FINALIZE_OPTIONS, DEFAULT_RESPONSE_COMPRESSION_SETTINGS, } from "./LambderResponse.js";
4
4
  import { compileRouteMatcher } from "./LambderRouting.js";
5
- import { applyCorsHeaders } from "./LambderCors.js";
5
+ import { allowedCorsOriginOf, applyCorsHeaders } from "./LambderCors.js";
6
6
  import LambderSessionManager from "../session/LambderSessionManager.js";
7
7
  import { resolveCompressionOption } from "../shared/wire/LambderCompressionOption.js";
8
+ import { DEFAULT_API_PATH } from "../shared/wire/LambderDefaultApiPath.js";
9
+ import { LambderSessionNotFoundError } from "../session/LambderSessionController.js";
8
10
  import { LambderPublicFilesHandler } from "./LambderPublicFiles.js";
9
11
  import { LambderIndexHtmlHandler } from "./LambderIndexHtml.js";
10
12
  import { LambderFiles } from "./LambderFiles.js";
@@ -13,10 +15,12 @@ import { LambderApiPipeline } from "../api/LambderApiPipeline.js";
13
15
  import { LAMBDER_BACKEND_SWAP, LAMBDER_CRASH_WATCH } from "../shared/util/LambderTestingDoors.js";
14
16
  import { apiSignatureOf } from "../api/LambderApiSignature.js";
15
17
  import { apiNameKeyOf } from "../shared/wire/LambderApiSignature.js";
16
- import { apiNotFoundAnswer, crashAnswer, refusalAnswer, } from "../api/LambderApiEnvelope.js";
17
- import { createContext, isV2HttpEvent } from "./LambderContext.js";
18
+ import { apiNotFoundAnswer, refusalAnswer, sessionExpiredAnswer, } from "../api/LambderApiEnvelope.js";
19
+ import { bindContextTools, createContext, isV2HttpEvent, } from "./LambderContext.js";
18
20
  import { COMPRESSED_PAYLOAD_GZ_FIELD, COMPRESSED_PAYLOAD_BR_FIELD, COMPRESSED_PAYLOAD_BYTES_FIELD } from "../shared/wire/LambderRequestPayload.js";
19
21
  import { coerceToError } from "../shared/wire/LambderCrashDetail.js";
22
+ import { LambderCrashHandling } from "./LambderCrashHandling.js";
23
+ import { policyBuildersFor } from "./LambderPolicyBuilders.js";
20
24
  import { assertCreateOptions, } from "./LambderCreateOptions.js";
21
25
  /**
22
26
  * Main Lambder class for building type-safe serverless APIs. Create
@@ -31,7 +35,7 @@ import { assertCreateOptions, } from "./LambderCreateOptions.js";
31
35
  * @typeParam _TIdempotencyEnabled - @internal True when create() received idempotency (do not pass manually)
32
36
  * @typeParam _TSessionGuardsRequired - @internal True when create() received requireSessionApiGuards (do not pass manually)
33
37
  * @typeParam _TPublicGuardsRequired - @internal True when create() received requirePublicApiGuards (do not pass manually)
34
- * @typeParam _TSessionsEnabled - @internal True when create() received the session option (do not pass manually). It defaults to TRUE, unlike its siblings: a plugin module annotates its parameter as the bare Lambder<SessionData>, and that annotation has to keep registering session APIs. create() is where the option is actually known, so create() is where the false comes from; `new Lambder(...)` keeps only the registration-time throw.
38
+ * @typeParam _TSessionsEnabled - @internal True when create() received the session option (do not pass manually). Defaults to true, unlike its siblings, so a plugin annotating its parameter as the bare Lambder<SessionData> can still register session APIs. create() knows the option and supplies the false; `new Lambder(...)` relies on the registration-time throw alone.
35
39
  *
36
40
  * @example
37
41
  * ```typescript
@@ -53,20 +57,19 @@ export default class Lambder {
53
57
  /** The instance's file reader (source + caches), or null without the files option. */
54
58
  files;
55
59
  /**
56
- * Type property for extracting the API contract
57
- * Use this to export your API types to the frontend
60
+ * Type property for extracting the API contract: every registered API's
61
+ * input, output, mode and declared options, as a client calls it.
58
62
  *
59
- * Export it as an interface extending LambderFlattenContract, not as a
60
- * type alias. Chaining builds the contract as an intersection one member
61
- * deep per endpoint, and an interface collapses that into one declared
62
- * set of members, which every generic read of the contract (a mock
63
- * registry, a needs map, the typed caller) is then far cheaper against.
64
- * See LambderFlattenContract for the measurements.
63
+ * A small app's client imports it as it is. It is an intersection one
64
+ * member deep per endpoint, so reading it generically costs a large app's
65
+ * client most of its type check; writeApiContract (lambder/build) reads
66
+ * this property off the exported instance and writes the contract out as
67
+ * plain types for such a client to import instead.
65
68
  *
66
69
  * @example
67
70
  * ```typescript
68
- * const lambder = new Lambder().addApi(...).addApi(...);
69
- * export interface ApiContractType extends LambderFlattenContract<typeof lambder.ApiContract> {}
71
+ * export const lambder = initLambder().create({ ... }).addApi(...).addApi(...);
72
+ * export type ApiContractType = typeof lambder.ApiContract;
70
73
  * ```
71
74
  */
72
75
  ApiContract;
@@ -93,17 +96,24 @@ export default class Lambder {
93
96
  requireSessionApiGuards;
94
97
  /** Told what a request threw, beside whatever answers it; null outside a test. See LAMBDER_CRASH_WATCH. */
95
98
  crashWatcher = null;
99
+ /** The crashes option applied: reporting, and the framework's own 500. */
100
+ crashHandling;
101
+ /** What this instance binds onto every context it renders (ctx.sessionController, ctx.rateLimit, ctx.isRateLimited). */
102
+ contextTools;
96
103
  trustedClientIpHeaders;
104
+ trustedHostHeaders;
97
105
  requirePublicApiGuards;
98
106
  constructor(options = {}) {
99
107
  assertCreateOptions(options);
100
108
  this.files = options.files ? new LambderFiles(options.files) : null;
101
- this.apiPath = options.apiPath ?? "/api";
109
+ this.apiPath = options.apiPath ?? DEFAULT_API_PATH;
102
110
  this.apiVersion = options.apiVersion ?? null;
111
+ // Resolved (and validated) by the same function the at-rest stores
112
+ // use; on unless explicitly disabled, except on a REST API, where it
113
+ // is on only when the app names it: see LambderFinalizeOptions.
114
+ const compression = resolveCompressionOption(options.compression, DEFAULT_RESPONSE_COMPRESSION_SETTINGS);
103
115
  this.finalizeOptions = {
104
- // Resolved (and validated) by the same function the at-rest
105
- // stores use; on unless explicitly disabled.
106
- compression: resolveCompressionOption(options.compression, DEFAULT_RESPONSE_COMPRESSION_SETTINGS),
116
+ compression: { v1: options.compression === undefined ? null : compression, v2: compression },
107
117
  etag: options.etag ?? DEFAULT_FINALIZE_OPTIONS.etag,
108
118
  maxResponseBytes: options.maxResponseBytes ?? DEFAULT_FINALIZE_OPTIONS.maxResponseBytes,
109
119
  };
@@ -140,8 +150,34 @@ export default class Lambder {
140
150
  idempotency: options.idempotency,
141
151
  });
142
152
  this.trustedClientIpHeaders = options.trustedClientIpHeaders ?? [];
153
+ this.trustedHostHeaders = options.trustedHostHeaders ?? [];
143
154
  this.requireSessionApiGuards = options.requireSessionApiGuards ?? false;
144
155
  this.requirePublicApiGuards = options.requirePublicApiGuards ?? false;
156
+ this.crashHandling = new LambderCrashHandling(options.crashes ?? {}, this.apiVersion);
157
+ this.contextTools = {
158
+ sessionControllerFor: (ctx) => this.getSessionController(ctx),
159
+ chargeRateLimit: async (ctx, policy, key, refuse) => {
160
+ const { checkResult, refusal } = await this.pipeline.chargeRateLimit(policy, {
161
+ // A per-API budget counts per registered API. The posted
162
+ // name of a call no API matched (a hook or the fallback
163
+ // charging it) is the caller's choice, and a fresh name
164
+ // per request would be a fresh counter.
165
+ apiName: ctx.api && this.apiDefinitions.has(ctx.api.apiName) ? ctx.api.apiName : null,
166
+ ip: ctx.ip,
167
+ session: ctx.session,
168
+ key,
169
+ });
170
+ if (refuse && refusal) {
171
+ if (ctx.api)
172
+ throw refusal;
173
+ // A route has no envelope to carry a refusal, so it
174
+ // answers the same 429 as text, with the same Retry-After
175
+ // and the policy's own words.
176
+ throw this.getResolver(ctx).text(refusal.errorMessage.content, { statusCode: 429, headers: refusal.headers });
177
+ }
178
+ return checkResult;
179
+ },
180
+ };
145
181
  }
146
182
  // =====================================================================
147
183
  // Registration
@@ -163,7 +199,13 @@ export default class Lambder {
163
199
  this.globalErrorHandler = globalErrorHandler;
164
200
  return this;
165
201
  }
166
- /** Response for session routes when the session is missing/expired (non-API). Default: 401. */
202
+ /**
203
+ * Response for a session route when the session is missing or expired,
204
+ * and for any non-API request whose route or hook meets a
205
+ * LambderSessionNotFoundError (the session ended while the request held
206
+ * it, or a session read found none, or cookies naming several: a
207
+ * LambderSessionAmbiguousError). Default: 401.
208
+ */
167
209
  setSessionExpiredRouteHandler(handler) {
168
210
  this.sessionExpiredRouteHandler = handler;
169
211
  return this;
@@ -173,10 +215,10 @@ export default class Lambder {
173
215
  * never shadow routes registered after it. Serves files from the `files`
174
216
  * source configured at creation, under the reader's path rule, mime-typed,
175
217
  * memory-cached, with the immutable-cache heuristic for content-hashed
176
- * assets. Only configured methods reach it, default GET/HEAD, as for
177
- * serveIndexHtml; a gated-out method and a path the source has no file
178
- * for both fall through to setRouteFallbackHandler, where the app decides
179
- * what remains (e.g. render an app shell with res.templateFile).
218
+ * assets. Only configured methods reach it (default GET/HEAD); a
219
+ * gated-out method or a path with no file falls through to
220
+ * setRouteFallbackHandler, where the app decides what remains (e.g.
221
+ * render an app shell with res.templateFile).
180
222
  */
181
223
  servePublicFiles(options = {}) {
182
224
  if (!this.files)
@@ -218,29 +260,51 @@ export default class Lambder {
218
260
  }
219
261
  // Typed API with Zod
220
262
  addApi(name, schema, handler) {
221
- this.assertApiRegistration(name, "public", schema);
222
- const definition = { name, mode: "public", guards: schema.guards, rateLimit: schema.rateLimit, idempotency: schema.idempotency, input: schema.input, output: schema.output };
223
- this.apiDefinitions.set(name, definition);
224
- this.actionList.push({
225
- match: (ctx) => ctx.apiName === name ? {} : false,
226
- actionFn: (ctx, resolver) => this.runApi(ctx, resolver, definition, handler),
227
- });
263
+ this.registerApi(name, "public", schema, handler);
228
264
  return this;
229
265
  }
230
266
  // Typed Session API with Zod
231
267
  addSessionApi(name, schema, handler) {
232
- this.assertApiRegistration(name, "session", schema);
233
- const definition = { name, mode: "session", guards: schema.guards, rateLimit: schema.rateLimit, idempotency: schema.idempotency, input: schema.input, output: schema.output };
268
+ this.registerApi(name, "session", schema, handler);
269
+ return this;
270
+ }
271
+ /**
272
+ * What registering an API is, for addApi and addSessionApi alike: the
273
+ * checks that can refuse it, then its definition recorded (what
274
+ * apiSignatures() digests) and its action appended to the first-match
275
+ * chain. The two public methods differ only in the mode and in the types
276
+ * they give the handler.
277
+ */
278
+ registerApi(name, mode, schema, handler) {
279
+ if (this.apiDefinitions.has(name)) {
280
+ throw new Error(`Lambder: duplicate API name "${name}". Dispatch is first-match, so the second registration would be silently dead code.`);
281
+ }
282
+ // Everything that can refuse the registration runs before the name is
283
+ // claimed below: a refusal the app catches and fixes would otherwise
284
+ // leave the name taken, and the retry would report a duplicate
285
+ // instead of the problem it was fixing.
286
+ if (mode === "session" && !this.pipeline.hasSessions) {
287
+ throw new Error(`Lambder: session API "${name}" needs the session option at creation.`);
288
+ }
289
+ const guardsRequired = mode === "session" ? this.requireSessionApiGuards : this.requirePublicApiGuards;
290
+ if (guardsRequired && schema.guards === undefined) {
291
+ const optOut = mode === "session"
292
+ ? "the named no-op guard that marks the session itself as the whole authorization"
293
+ : "the named no-op guard that records why anyone may call it";
294
+ throw new Error(`Lambder: ${mode} API "${name}" declares no guards, and require${mode === "session" ? "Session" : "Public"}ApiGuards is on. ` +
295
+ `Declare the guard that authorizes it, or ${optOut}.`);
296
+ }
297
+ const definition = { name, mode, guards: schema.guards, rateLimit: schema.rateLimit, idempotency: schema.idempotency, input: schema.input, output: schema.output };
298
+ this.pipeline.assertRegistration(definition);
234
299
  this.apiDefinitions.set(name, definition);
235
300
  this.actionList.push({
236
301
  match: (ctx) => ctx.apiName === name ? {} : false,
237
- actionFn: (ctx, resolver) => this.runApi(ctx, resolver, definition, handler),
302
+ actionFn: (ctx) => this.runApi(ctx, definition, handler),
238
303
  });
239
- return this;
240
304
  }
241
305
  addHook(hookEvent, hookFn, priority = 0) {
242
306
  if (hookEvent === "created") {
243
- // Runs once, lazily, at the first render() call.
307
+ // Runs once, lazily, before the first request or event is handled.
244
308
  this.createdHooks.push(hookFn);
245
309
  }
246
310
  else {
@@ -272,11 +336,10 @@ export default class Lambder {
272
336
  // The policy generics are `any` in the plugin signature on purpose: a
273
337
  // module may annotate its parameter as the bare Lambder<SessionData> or
274
338
  // as the app's narrowed alias, and both must chain. Registration-time
275
- // assertions still verify every referenced policy/guard name at runtime.
276
- // Every policy generic must be listed here: one short of the class's
277
- // parameter list and the missing one silently falls back to its default,
278
- // which makes an instance carrying the non-default value unassignable to
279
- // its own plugins.
339
+ // assertions still check every referenced policy/guard name. Every
340
+ // policy generic must be listed: a missing one falls back to its default,
341
+ // making an instance with a non-default value unassignable to its own
342
+ // plugins.
280
343
  use(plugin) {
281
344
  return plugin(this);
282
345
  }
@@ -285,9 +348,11 @@ export default class Lambder {
285
348
  // What a handler or an app asks the instance for.
286
349
  // =====================================================================
287
350
  /**
288
- * Sessions for this request: what handlers create, rotate, refresh and
289
- * end sessions with. An API call presents its posted CSRF token; a route
290
- * presents cookies alone.
351
+ * A session controller for a context: what creates, rotates, refreshes
352
+ * and ends sessions. An API call presents its posted CSRF token; a route
353
+ * presents cookies alone. A context this instance renders already
354
+ * carries one as `ctx.sessionController`; this is for a context it did
355
+ * not render, such as one createContext() built from an event on its own.
291
356
  */
292
357
  getSessionController(ctx) {
293
358
  const context = ctx;
@@ -320,26 +385,24 @@ export default class Lambder {
320
385
  /**
321
386
  * Every registered endpoint's signature, keyed by its hashed name: the
322
387
  * LambderApiSignatureMap both sides ship with. A generator imports the
323
- * finished instance, awaits this, and writes the result to a file the
324
- * frontend passes to LambderCaller as apiSignatures and the server passes
325
- * to create() as apiSignatures; at request time the pipeline compares a
326
- * call's signature with the server's copy of the same map. This is the
327
- * one place a digest is computed, so it has nothing to agree with but
328
- * itself. Keys are sorted, so the generated file diffs by endpoint.
388
+ * finished instance, awaits this, and writes a file that LambderCaller
389
+ * and create() both take as apiSignatures; at request time the pipeline
390
+ * compares a call's signature with the server's copy. This is the only
391
+ * place a digest is computed, so there is no second computation to drift
392
+ * from it. Keys are sorted, so the generated file diffs by endpoint.
329
393
  */
330
394
  async apiSignatures() {
331
395
  return Object.fromEntries((await this.apiSignatureEntries()).map(({ key, signature }) => [key, signature]));
332
396
  }
333
397
  /**
334
- * The same signatures with the endpoint name each one was digested from,
335
- * sorted by key as the map is. What apiSignatures() leaves out on purpose:
336
- * the map a client ships lists no names, so a generator that only had the
337
- * map could report that four signatures changed but not which endpoints.
338
- * Reading this instead, it can name them.
398
+ * The same signatures, sorted by key as the map is, with the endpoint
399
+ * name each was digested from. The map a client ships deliberately lists
400
+ * no names, so a generator reading only the map could say how many
401
+ * signatures changed but not which endpoints; this lets it name them.
339
402
  *
340
- * A build-time view by construction. It comes off the server instance,
341
- * which a generator imports and a client never does, so nothing here
342
- * reaches a bundle unless the generator writes it there.
403
+ * A build-time view: it comes off the server instance, which a generator
404
+ * imports and a client never does, so nothing here reaches a bundle
405
+ * unless the generator writes it there.
343
406
  */
344
407
  async apiSignatureEntries() {
345
408
  const entries = await Promise.all([...this.apiDefinitions.values()].map(async (definition) => ({
@@ -392,32 +455,35 @@ export default class Lambder {
392
455
  })();
393
456
  this.initPromise = pending;
394
457
  // A `created` hook usually reaches something that can be briefly
395
- // unavailable (a first DynamoDB read, a secret fetch). Keeping the
396
- // rejected promise meant the warm container answered every later
397
- // invocation with the first failure and never recovered, so the
398
- // failure is forgotten and the next invocation runs the hooks
399
- // again. Whoever is awaiting this one still gets the rejection.
458
+ // unavailable (a first DynamoDB read, a secret fetch). A kept
459
+ // rejection would answer every later invocation on the warm
460
+ // container with that first failure, so it is forgotten and the
461
+ // next invocation runs the hooks again. Whoever is awaiting this
462
+ // one still gets the rejection.
400
463
  pending.catch(() => { if (this.initPromise === pending)
401
464
  this.initPromise = null; });
402
465
  }
403
466
  return this.initPromise;
404
467
  }
405
- applyCors(ctx, response, isPreflight) {
406
- applyCorsHeaders(this.corsConfig, ctx, response, isPreflight);
468
+ applyCors(allowedOrigin, response, isPreflight) {
469
+ applyCorsHeaders(this.corsConfig, allowedOrigin, response, isPreflight);
407
470
  }
408
471
  /**
409
472
  * The beforeRender hooks, in priority order: the replaced context to
410
473
  * continue with, or the response one of them answered with.
411
474
  *
412
- * Its own method because BOTH request paths run it. Left inline after the
413
- * match, it ran for routes and APIs and for nothing else, so a
414
- * servePublicFiles or serveIndexHtml answer, which is every asset and
415
- * every app-shell page, skipped the one hook that can inspect a request,
416
- * replace its context or short-circuit it: a security header written in a
417
- * hook reached the API answers and not the HTML it was written for, and a
418
- * maintenance-mode hook served the whole frontend anyway.
475
+ * Its own method because both request paths run it. Run only after a
476
+ * match, it would skip every servePublicFiles and serveIndexHtml answer
477
+ * (every asset and app-shell page): a security header written in a hook
478
+ * would miss the HTML it was written for, and a maintenance-mode hook
479
+ * would still serve the whole frontend.
480
+ *
481
+ * Each replacement is also handed to `onContextReplaced` as it is made,
482
+ * rather than only returned: render() answers from it after the handler
483
+ * too (the afterRender hooks, a crash's report, reveal and global error
484
+ * handler), and a handler or a later hook that throws returns nothing.
419
485
  */
420
- async runBeforeRenderHooks(ctx, resolver) {
486
+ async runBeforeRenderHooks(ctx, resolver, onContextReplaced) {
421
487
  let currentCtx = ctx;
422
488
  for (const hook of this.hookList["beforeRender"]) {
423
489
  const hookResult = await hook.hookFn(currentCtx, resolver);
@@ -425,14 +491,20 @@ export default class Lambder {
425
491
  throw hookResult;
426
492
  if (hookResult instanceof LambderResponse)
427
493
  return hookResult;
428
- currentCtx = hookResult;
494
+ if (hookResult === currentCtx)
495
+ continue;
496
+ // A hook that answered with a new object (`{ ...ctx, extra }`)
497
+ // carries none of the tools, which are not enumerable, so they are
498
+ // bound again, onto the object the rest of the request uses.
499
+ currentCtx = bindContextTools(hookResult, this.contextTools);
500
+ onContextReplaced(currentCtx);
429
501
  }
430
502
  return currentCtx;
431
503
  }
432
- async handleNoMatchedAction(ctx, resolver) {
504
+ async handleNoMatchedAction(ctx, resolver, onContextReplaced) {
433
505
  // Before the fallback hooks, and with the same power it has on a
434
506
  // matched route: the fallback hooks are typed void and cannot answer.
435
- const beforeRenderResult = await this.runBeforeRenderHooks(ctx, resolver);
507
+ const beforeRenderResult = await this.runBeforeRenderHooks(ctx, resolver, onContextReplaced);
436
508
  if (beforeRenderResult instanceof LambderResponse)
437
509
  return beforeRenderResult;
438
510
  const currentCtx = beforeRenderResult;
@@ -443,7 +515,7 @@ export default class Lambder {
443
515
  if (isAPI) {
444
516
  if (this.apiFallbackHandler)
445
517
  return await this.apiFallbackHandler(currentCtx, resolver);
446
- return responseFromAnswer(currentCtx.api ? this.pipeline.answerUnknownApi(currentCtx.api, currentCtx) : apiNotFoundAnswer(this.apiVersion, currentCtx.logList));
518
+ return responseFromAnswer(currentCtx.api ? this.pipeline.answerUnknownApi(currentCtx) : apiNotFoundAnswer(this.apiVersion, currentCtx.logList));
447
519
  }
448
520
  if (this.publicFilesHandler) {
449
521
  const fileResponse = await this.publicFilesHandler.handle(currentCtx);
@@ -459,16 +531,16 @@ export default class Lambder {
459
531
  }
460
532
  /**
461
533
  * True for the OPTIONS request the CORS layer answers by itself. Asked
462
- * twice: once to build the 204, once at the end of render() to decide
463
- * which form of the headers goes on. Asking once and letting the tail
464
- * apply the ordinary headers on top of the 204's would put both forms on
465
- * a preflight, answering `Vary: Origin, Origin` and an
466
- * Access-Control-Expose-Headers that means nothing before a request.
534
+ * twice: once to build the 204, once at the end of render() to pick which
535
+ * form of the headers goes on. Applying the ordinary headers on top of
536
+ * the 204's would put both forms on a preflight: `Vary: Origin, Origin`
537
+ * and an Access-Control-Expose-Headers that means nothing before a
538
+ * request.
467
539
  */
468
540
  isCorsPreflight(ctx) {
469
541
  return ctx.method === "OPTIONS" && !!this.corsConfig;
470
542
  }
471
- async resolveRequest(ctx, resolver) {
543
+ async resolveRequest(ctx, resolver, onContextReplaced) {
472
544
  if (this.isCorsPreflight(ctx))
473
545
  return new LambderResponse({ statusCode: 204, body: null });
474
546
  if (ctx.api) {
@@ -496,44 +568,51 @@ export default class Lambder {
496
568
  }
497
569
  }
498
570
  if (!matched)
499
- return await this.handleNoMatchedAction(ctx, resolver);
571
+ return await this.handleNoMatchedAction(ctx, resolver, onContextReplaced);
500
572
  // Set before the hooks run, so a beforeRender hook on a matched route
501
573
  // sees the route's own path params.
502
574
  ctx.pathParams = matched.params;
503
- const beforeRenderResult = await this.runBeforeRenderHooks(ctx, resolver);
575
+ const beforeRenderResult = await this.runBeforeRenderHooks(ctx, resolver, onContextReplaced);
504
576
  if (beforeRenderResult instanceof LambderResponse)
505
577
  return beforeRenderResult;
506
578
  return await matched.action.actionFn(beforeRenderResult, resolver);
507
579
  }
508
580
  async render(event, lambdaContext) {
509
581
  let ctx = null;
582
+ let started = false;
583
+ // Settled as soon as the context exists and reused by every answer,
584
+ // the crash path's included; see allowedCorsOriginOf.
585
+ let allowedOrigin = null;
510
586
  try {
511
587
  await this.ensureInitialized();
512
- ctx = createContext(event, lambdaContext, this.apiPath, this.trustedClientIpHeaders);
588
+ started = true;
589
+ ctx = bindContextTools(createContext(event, lambdaContext, {
590
+ apiPath: this.apiPath,
591
+ trustedClientIpHeaders: this.trustedClientIpHeaders,
592
+ trustedHostHeaders: this.trustedHostHeaders,
593
+ }), this.contextTools);
594
+ if (this.corsConfig)
595
+ allowedOrigin = allowedCorsOriginOf(this.corsConfig, ctx);
513
596
  const resolver = this.getResolver(ctx);
514
597
  let response;
515
598
  try {
516
- response = await this.resolveRequest(ctx, resolver);
599
+ // A context a beforeRender hook hands back is the request's
600
+ // from then on, here as in the handler: the afterRender hooks,
601
+ // a thrown answer and a crash's report, reveal and global
602
+ // error handler all read what the hook added, and the session
603
+ // a session route or API read onto it.
604
+ response = await this.resolveRequest(ctx, resolver, (replacement) => { ctx = replacement; });
517
605
  }
518
606
  catch (err) {
519
- // A thrown LambderResponse IS the response (res.die.*, throw res.html(...)).
520
- if (err instanceof LambderResponse) {
521
- response = err;
522
- }
523
- // A thrown LambderApiRefusal on an API call IS a structured refusal
524
- // (brand-checked, not instanceof, to survive duplicate installs).
525
- else if (isLambderApiRefusal(err) && ctx.api) {
526
- response = this.apiErrorResponse(err, ctx);
527
- }
528
- else {
529
- throw err;
530
- }
607
+ response = await this.answerThrown(err, ctx, resolver);
531
608
  }
532
- // What the call wrote goes on BEFORE the hooks run, so an
609
+ // Everything from here writes into the response, and the object a
610
+ // handler answered with may be one it keeps between requests.
611
+ response = response.copy();
612
+ // What the call wrote goes on before the hooks run, so an
533
613
  // afterRender hook can override or delete a header the handler
534
- // wrote: replaying the operations afterwards put the handler's
535
- // value straight back, and a hook could not win an argument it
536
- // was the last to speak in.
614
+ // wrote. Applied afterwards, it would put the handler's value
615
+ // straight back over the hook's.
537
616
  const responseIntoHooks = response;
538
617
  const headersAppliedIntoHooks = ctx.responseHeaders.size;
539
618
  ctx.responseHeaders.applyTo(response);
@@ -542,143 +621,183 @@ export default class Lambder {
542
621
  const hookResponse = await hook.hookFn(ctx, resolver, response);
543
622
  if (hookResponse instanceof Error)
544
623
  throw hookResponse;
545
- response = hookResponse;
624
+ // A response a hook answers with may be one it keeps
625
+ // between requests, like a handler's, and the hooks after
626
+ // it write into it: copied for the same reason.
627
+ if (hookResponse !== response)
628
+ response = hookResponse.copy();
546
629
  }
547
630
  }
548
631
  catch (err) {
549
- if (err instanceof LambderResponse) {
550
- response = err;
551
- }
552
- else if (isLambderApiRefusal(err) && ctx.api) {
553
- response = this.apiErrorResponse(err, ctx);
554
- }
555
- else {
556
- throw err;
557
- }
632
+ response = (await this.answerThrown(err, ctx, resolver)).copy();
558
633
  }
559
634
  // Only what the hooks themselves wrote (res.setHeader inside a
560
- // hook) is left to apply, which is what leaves their overrides
561
- // standing. A hook that answered with a DIFFERENT response takes
562
- // the whole set instead: headers belong to the call rather than to
563
- // the response that first carried them, so the session cookie the
564
- // call wrote has to travel across to it.
635
+ // hook) is left to apply, which leaves their overrides standing.
636
+ // A hook that answered with a different response takes the whole
637
+ // set instead: headers belong to the call, not to the response
638
+ // that first carried them, so the call's session cookie must
639
+ // reach it.
565
640
  ctx.responseHeaders.applyTo(response, response === responseIntoHooks ? headersAppliedIntoHooks : 0);
566
- this.applyCors(ctx, response, this.isCorsPreflight(ctx));
641
+ this.applyCors(allowedOrigin, response, this.isCorsPreflight(ctx));
567
642
  return await finalizeResponse(ctx, response, this.finalizeOptions, ctx.eventFormat);
568
643
  }
569
644
  catch (err) {
570
- // Describing the thrown value can itself throw: an object with a
571
- // null prototype, a Proxy, or a throwing toString/Symbol.toPrimitive.
572
- // Coercing it unguarded in the FIRST statement of the last-resort
573
- // catch made the catch throw, so the error handler never ran, no
574
- // envelope was produced, and the invocation rejected with a 502 no
575
- // client could parse. coerceToError is the shared version of that
576
- // care, the one every site in the framework now uses.
577
- const wrappedError = coerceToError(err, "an unstringifiable thrown value");
578
- this.crashWatcher?.(wrappedError);
579
- // ctx may be null (createContext failed): derive the format from the raw event.
580
- const eventFormat = ctx?.eventFormat ?? (isV2HttpEvent(event) ? "v2" : "v1");
581
- try {
582
- if (this.globalErrorHandler) {
583
- const responseBuilder = this.getResponseBuilder(ctx ?? undefined);
584
- const errorResponse = await this.globalErrorHandler(wrappedError, ctx, responseBuilder);
585
- // The same rule the success path follows: headers belong
586
- // to the call, not to the response that first carried
587
- // them. A call that wrote a session cookie and then threw
588
- // still owes the browser that cookie, and a cross-origin
589
- // caller cannot read the error at all without the CORS
590
- // headers.
591
- ctx?.responseHeaders.applyTo(errorResponse);
592
- if (ctx)
593
- this.applyCors(ctx, errorResponse, false);
594
- return await finalizeResponse(ctx, errorResponse, this.finalizeOptions, eventFormat);
595
- }
645
+ return await this.answerCrash(err, ctx, allowedOrigin, started, event, lambdaContext);
646
+ }
647
+ }
648
+ /**
649
+ * A thrown value that is an answer rather than a crash, as the response;
650
+ * anything else is rethrown to the crash path.
651
+ *
652
+ * - A LambderResponse IS the response (res.die.*, throw res.html(...)).
653
+ * - A LambderApiRefusal on an API call is its structured refusal
654
+ * (brand-checked, not instanceof, to survive duplicate installs).
655
+ * - A LambderSessionNotFoundError is a missing session: one that ended
656
+ * while the request held it (a logout or a password change landing
657
+ * mid-request), or one a route or hook asked for that the request
658
+ * never had or whose cookies named several (its subclass
659
+ * LambderSessionAmbiguousError). It is answered the way a missing
660
+ * session is answered here, the decision the API pipeline makes for a
661
+ * handler, applied to the routes and hooks it never sees.
662
+ */
663
+ async answerThrown(thrown, ctx, resolver) {
664
+ if (thrown instanceof LambderResponse)
665
+ return thrown;
666
+ if (isLambderApiRefusal(thrown) && ctx.api)
667
+ return this.apiErrorResponse(thrown, ctx);
668
+ if (thrown instanceof LambderSessionNotFoundError)
669
+ return await this.sessionMissingResponse(ctx, resolver);
670
+ throw thrown;
671
+ }
672
+ /**
673
+ * A crash, from the thrown value to the answer. It is told to the test
674
+ * watch and reported before anything answers, so the report depends on
675
+ * nothing the answer might break; then the app's global error handler
676
+ * answers it, or the framework's own 500 does when there is none or it
677
+ * failed too.
678
+ */
679
+ async answerCrash(thrown, ctx, allowedOrigin, started, event, lambdaContext) {
680
+ // Describing the thrown value can itself throw (a null-prototype
681
+ // object, a Proxy, a throwing toString/Symbol.toPrimitive). Unguarded,
682
+ // the crash path would throw too, no handler would run, and the
683
+ // invocation would reject with a 502 no client can parse.
684
+ const error = coerceToError(thrown, "an unstringifiable thrown value");
685
+ this.crashWatcher?.(error);
686
+ const site = !started ? { kind: "startup", lambdaContext }
687
+ : ctx?.api ? { kind: "api", ctx, lambdaContext }
688
+ : { kind: "route", ctx, lambdaContext };
689
+ await this.crashHandling.report(error, site);
690
+ // ctx may be null (createContext failed): derive the format from the raw event.
691
+ const eventFormat = ctx?.eventFormat ?? (isV2HttpEvent(event) ? "v2" : "v1");
692
+ let errorHandlerCrash = null;
693
+ try {
694
+ if (this.globalErrorHandler) {
695
+ const errorResponse = await this.globalErrorHandler(error, ctx, this.getResponseBuilder(ctx ?? undefined));
696
+ return await finalizeResponse(ctx, this.withCallHeaders(ctx, allowedOrigin, errorResponse), this.finalizeOptions, eventFormat);
596
697
  }
597
- catch (handlerErr) {
598
- if (handlerErr instanceof LambderResponse) {
599
- ctx?.responseHeaders.applyTo(handlerErr);
600
- if (ctx)
601
- this.applyCors(ctx, handlerErr, false);
602
- try {
603
- return await finalizeResponse(ctx, handlerErr, this.finalizeOptions, eventFormat);
604
- }
605
- catch { /* fall through */ }
698
+ }
699
+ catch (handlerErr) {
700
+ if (handlerErr instanceof LambderResponse) {
701
+ try {
702
+ return await finalizeResponse(ctx, this.withCallHeaders(ctx, allowedOrigin, handlerErr), this.finalizeOptions, eventFormat);
606
703
  }
704
+ catch { /* fall through */ }
705
+ }
706
+ else {
707
+ // A second crash, in the code meant to answer the first:
708
+ // reported in its own right, with the thrown value as its
709
+ // cause, so neither of the two disappears.
710
+ errorHandlerCrash = new Error("Lambder: the global error handler threw while answering a crash.", {
711
+ cause: coerceToError(handlerErr, "an unstringifiable thrown value"),
712
+ });
713
+ await this.crashHandling.report(errorHandlerCrash, site);
607
714
  }
608
- // Last-resort 500. API calls get the core's crash envelope so
609
- // clients can parse a structured failure; everything else keeps
610
- // plain text. Emitted directly rather than finalized, because
611
- // finalization may be what failed. The headers still go on: they
612
- // belong to the call and not to the response that first carried
613
- // them, so a call that wrote a session cookie and then threw still
614
- // owes the browser that cookie, and a cross-origin caller cannot
615
- // read this error at all without the CORS headers. Applying them
616
- // is plain object work, none of the compression, base64 or size
617
- // handling that finalization does.
618
- const crashResponse = ctx?.api
619
- ? responseFromAnswer(crashAnswer(this.apiVersion))
620
- : new LambderResponse({ statusCode: 500, body: "Internal Server Error." });
621
- ctx?.responseHeaders.applyTo(crashResponse);
622
- if (ctx)
623
- this.applyCors(ctx, crashResponse, false);
624
- return emitResponse(eventFormat, crashResponse.statusCode, crashResponse.headers, typeof crashResponse.body === "string" ? crashResponse.body : "", false);
625
715
  }
716
+ // Emitted directly rather than finalized, because finalization may be
717
+ // what failed; applying the call's headers is plain object work, none
718
+ // of the compression, base64 or size handling finalization does.
719
+ const crashResponse = this.withCallHeaders(ctx, allowedOrigin, await this.crashHandling.frameworkResponse(error, ctx, errorHandlerCrash));
720
+ return emitResponse(eventFormat, crashResponse.statusCode, crashResponse.headers, typeof crashResponse.body === "string" ? crashResponse.body : "", false);
721
+ }
722
+ /**
723
+ * An answer to a crash, carrying what the call wrote and its CORS headers.
724
+ * As on the success path, headers belong to the call: a call that wrote a
725
+ * session cookie and then threw still owes the browser that cookie, and a
726
+ * cross-origin caller cannot read the error at all without CORS headers.
727
+ * The CORS verdict is the one the request settled before it crashed, so
728
+ * answering a crash runs none of the app's code.
729
+ */
730
+ withCallHeaders(ctx, allowedOrigin, answer) {
731
+ // A copy, as on the success path: an error handler may answer with an object it keeps.
732
+ const response = answer.copy();
733
+ if (!ctx)
734
+ return response;
735
+ ctx.responseHeaders.applyTo(response);
736
+ this.applyCors(allowedOrigin, response, false);
737
+ return response;
626
738
  }
627
739
  /**
628
740
  * Fetch the session for a session route or short-circuit it with the
629
- * sessionExpiredRouteHandler response (default 401). Session APIs never
630
- * come through here: the pipeline answers them with the protocol's
631
- * { sessionExpired: true } envelope itself.
741
+ * answer for a missing session. Session APIs never come through here:
742
+ * the pipeline answers them with the protocol's { sessionExpired: true }
743
+ * envelope itself.
632
744
  */
633
745
  async requireSession(ctx, resolver) {
634
746
  const session = await this.getSessionController(ctx).fetchSessionIfExists();
635
- if (!session) {
636
- if (this.sessionExpiredRouteHandler) {
637
- throw await this.sessionExpiredRouteHandler(ctx, resolver);
638
- }
639
- throw resolver.status(401, "Session required.");
747
+ if (!session)
748
+ throw await this.sessionMissingResponse(ctx, resolver);
749
+ }
750
+ /**
751
+ * The answer to a request that needed a session and has none, whether it
752
+ * never had one or it ended while the request held it: an API call gets
753
+ * the protocol's sessionExpired envelope, as the pipeline gives a session
754
+ * API, and anything else the setSessionExpiredRouteHandler answer, a 401
755
+ * by default.
756
+ */
757
+ async sessionMissingResponse(ctx, resolver) {
758
+ if (ctx.api)
759
+ return responseFromAnswer(sessionExpiredAnswer(this.apiVersion, ctx.logList));
760
+ if (!this.sessionExpiredRouteHandler)
761
+ return resolver.status(401, "Session required.");
762
+ try {
763
+ return await this.sessionExpiredRouteHandler(ctx, resolver);
764
+ }
765
+ catch (err) {
766
+ // It may answer by throwing, as any handler may.
767
+ if (err instanceof LambderResponse)
768
+ return err;
769
+ throw err;
640
770
  }
641
771
  }
642
- /** Dispatch a non-HTTP Lambda event to the registered actions. */
772
+ /**
773
+ * Dispatch a non-HTTP Lambda event to the registered actions. What an
774
+ * action throws is reported (crashes.report) and then rethrown untouched,
775
+ * so Lambda's retries and dead-letter queues still see the failure.
776
+ */
643
777
  async renderEvent(event, lambdaContext) {
644
- await this.ensureInitialized();
645
- for (const action of this.eventActionList) {
646
- if (action.match(event)) {
647
- return await action.actionFn(event, lambdaContext);
778
+ let started = false;
779
+ try {
780
+ await this.ensureInitialized();
781
+ started = true;
782
+ for (const action of this.eventActionList) {
783
+ if (action.match(event)) {
784
+ return await action.actionFn(event, lambdaContext);
785
+ }
648
786
  }
787
+ const summary = event && typeof event === "object"
788
+ ? ` (source: ${String(event.source ?? "?")}, detail-type: ${String(event["detail-type"] ?? "?")})`
789
+ : "";
790
+ throw new Error(`Lambder: no action matched non-HTTP event${summary}. Register one with addAction(); a trailing addAction(() => true, ...) acts as a fallback.`);
791
+ }
792
+ catch (err) {
793
+ await this.crashHandling.report(coerceToError(err, "an unstringifiable thrown value"), started ? { kind: "event", event, lambdaContext } : { kind: "startup", lambdaContext });
794
+ throw err;
649
795
  }
650
- const summary = event && typeof event === "object"
651
- ? ` (source: ${String(event.source ?? "?")}, detail-type: ${String(event["detail-type"] ?? "?")})`
652
- : "";
653
- throw new Error(`Lambder: no action matched non-HTTP event${summary}. Register one with addAction(); a trailing addAction(() => true, ...) acts as a fallback.`);
654
796
  }
655
797
  // =====================================================================
656
798
  // The API path
657
799
  // The steps only an API call takes, around the shared core.
658
800
  // =====================================================================
659
- /** Registration-time checks shared by addApi/addSessionApi. */
660
- assertApiRegistration(name, mode, options) {
661
- if (this.apiDefinitions.has(name)) {
662
- throw new Error(`Lambder: duplicate API name "${name}". Dispatch is first-match, so the second registration would be silently dead code.`);
663
- }
664
- // Everything that can refuse this registration runs before the name is
665
- // claimed. Claiming it first meant a caught registration error burned
666
- // the name, and the retry reported a duplicate instead of the problem
667
- // the app was fixing; the session check was still on the far side of
668
- // that line, one method down in addSessionApi.
669
- if (mode === "session" && !this.pipeline.hasSessions) {
670
- throw new Error(`Lambder: session API "${name}" needs the session option at creation.`);
671
- }
672
- const guardsRequired = mode === "session" ? this.requireSessionApiGuards : this.requirePublicApiGuards;
673
- if (guardsRequired && options.guards === undefined) {
674
- const optOut = mode === "session"
675
- ? "the named no-op guard that marks the session itself as the whole authorization"
676
- : "the named no-op guard that records why anyone may call it";
677
- throw new Error(`Lambder: ${mode} API "${name}" declares no guards, and require${mode === "session" ? "Session" : "Public"}ApiGuards is on. ` +
678
- `Declare the guard that authorizes it, or ${optOut}.`);
679
- }
680
- this.pipeline.assertRegistration({ name, mode, guards: options.guards, rateLimit: options.rateLimit, idempotency: options.idempotency });
681
- }
682
801
  /**
683
802
  * The answer for a rejected input: the app's
684
803
  * setApiInputValidationErrorHandler when set, otherwise the standard 422
@@ -699,12 +818,14 @@ export default class Lambder {
699
818
  * via res.die.*) becomes the answer the pipeline stores and hands back.
700
819
  * The context is the pipeline's context, so a session it fetched is on
701
820
  * ctx.session and the validated payload is on ctx.apiPayload when the
702
- * handler runs.
821
+ * handler runs. The handler's resolver knows the API's output schema, so
822
+ * every success payload is parsed through it before it is sent.
703
823
  */
704
- async runApi(ctx, resolver, definition, handler) {
824
+ async runApi(ctx, definition, handler) {
705
825
  const request = ctx.api;
706
826
  if (!request)
707
827
  throw new Error(`Lambder: API "${definition.name}" was matched by a request that is not an API call.`);
828
+ const resolver = new LambderResolver({ files: this.files, apiVersion: this.apiVersion, ctx, apiOutput: definition.output });
708
829
  // What the handler produced, in both forms: the answer went to the
709
830
  // pipeline, and the response is kept so it can carry on unchanged.
710
831
  const handled = { output: null };
@@ -725,13 +846,13 @@ export default class Lambder {
725
846
  handled.output = { response, answer: answerFromResponse(response) };
726
847
  return handled.output.answer;
727
848
  });
728
- // The handler's own response carries on when the pipeline answered
729
- // with it, rather than a rebuild of its answer. An answer holds a
730
- // Buffer body base64-encoded, because that is the plain shape the
731
- // idempotency store persists, and rebuilding a response from that
732
- // would hand finalization a base64 string it must pass through
733
- // uncompressed. Identity is what settles it: the pipeline may have
734
- // answered with a stored replay or a refusal instead.
849
+ // When the pipeline answered with the handler's own answer, its
850
+ // response carries on rather than a rebuild. An answer holds a Buffer
851
+ // body base64-encoded (the plain shape the idempotency store
852
+ // persists), and a response rebuilt from it would hand finalization a
853
+ // base64 string it must pass through uncompressed. Identity decides,
854
+ // since the pipeline may have answered with a stored replay or a
855
+ // refusal instead.
735
856
  if (handled.output?.answer === answer)
736
857
  return handled.output.response;
737
858
  return responseFromAnswer(answer);
@@ -743,11 +864,10 @@ export default class Lambder {
743
864
  }
744
865
  /**
745
866
  * The canonical way to create an instance: fix the session data type first,
746
- * then create with the full configuration in one declaration; the policy,
747
- * guard, and idempotency types are INFERRED from the options, so the
748
- * instance is born fully typed and `typeof lambderApp` is the annotation
749
- * type for api modules. No enable/define chain exists, so there are no
750
- * ordering rules and nothing can be half-configured.
867
+ * then create with the full configuration in one declaration. The policy,
868
+ * guard and idempotency types are inferred from the options, so the instance
869
+ * is born fully typed and `typeof lambderApp` is the annotation type for api
870
+ * modules. There are no ordering rules, and nothing can be half-configured.
751
871
  *
752
872
  * ```typescript
753
873
  * // app.ts (imports no api modules, so modules can import the type back)
@@ -768,15 +888,14 @@ export default class Lambder {
768
888
  * export const handler = lambder.getHandler();
769
889
  * ```
770
890
  *
771
- * Why curried (`initLambder<S>().create(...)` rather than
772
- * `new Lambder<S>(...)`): TypeScript type arguments are all-or-nothing per
773
- * call, so explicitly passing the session data type to the constructor
774
- * would silently WIDEN the inferred policy and guard types to their {}
775
- * defaults. Fixing the session type in the first call lets the second call
776
- * infer everything else from the options. `new Lambder(options)` remains
777
- * for untyped or session-data-free instances.
891
+ * Curried because TypeScript type arguments are all-or-nothing per call:
892
+ * passing the session data type to `new Lambder<S>(...)` would silently
893
+ * widen the inferred policy and guard types to their {} defaults. Fixing the
894
+ * session type in the first call lets the second infer everything else.
895
+ * `new Lambder(options)` serves untyped or session-data-free instances.
778
896
  */
779
897
  export const initLambder = () => ({
898
+ ...policyBuildersFor(),
780
899
  create(options) {
781
900
  return new Lambder(options);
782
901
  },