lambder 7.2.5 → 8.0.2

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