lambder 7.3.1 → 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 (207) hide show
  1. package/CHANGELOG.md +933 -3
  2. package/README.md +41 -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/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 +140 -75
  41. package/dist/core/Lambder.js +347 -227
  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 +21 -7
  51. package/dist/core/LambderFiles.js +62 -34
  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 +29 -9
  73. package/dist/invoke/LambderLambdaEvent.js +40 -22
  74. package/dist/invoke/lambderHandlerTransport.d.ts +9 -10
  75. package/dist/invoke/lambderHandlerTransport.js +15 -18
  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 +1 -1
  93. package/dist/mock.js +2 -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 +124 -46
  99. package/dist/session/LambderSessionManager.js +262 -137
  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/LambderTypeUtilities.d.ts +7 -8
  134. package/dist/shared/util/LambderTypeUtilities.js +3 -3
  135. package/dist/shared/util/boundKeyField.d.ts +20 -0
  136. package/dist/shared/util/boundKeyField.js +34 -0
  137. package/dist/shared/util/canonicalJson.d.ts +11 -0
  138. package/dist/shared/util/canonicalJson.js +28 -0
  139. package/dist/shared/util/joinKeyFields.d.ts +20 -0
  140. package/dist/shared/util/joinKeyFields.js +22 -0
  141. package/dist/shared/wire/LambderAnswerHeaders.d.ts +12 -16
  142. package/dist/shared/wire/LambderAnswerHeaders.js +12 -16
  143. package/dist/shared/wire/LambderApiContract.d.ts +107 -32
  144. package/dist/shared/wire/LambderApiOutcome.d.ts +43 -31
  145. package/dist/shared/wire/LambderApiOutcome.js +48 -23
  146. package/dist/shared/wire/LambderApiRefusal.d.ts +39 -27
  147. package/dist/shared/wire/LambderApiRefusal.js +36 -7
  148. package/dist/shared/wire/LambderApiSignature.d.ts +18 -22
  149. package/dist/shared/wire/LambderApiSignature.js +16 -19
  150. package/dist/shared/wire/LambderCallOptions.d.ts +38 -47
  151. package/dist/shared/wire/LambderCallOptions.js +9 -11
  152. package/dist/shared/wire/LambderCompressionCodec.d.ts +29 -34
  153. package/dist/shared/wire/LambderCompressionCodec.js +31 -36
  154. package/dist/shared/wire/LambderCompressionOption.d.ts +9 -9
  155. package/dist/shared/wire/LambderCompressionOption.js +9 -9
  156. package/dist/shared/wire/LambderCrashDetail.d.ts +12 -15
  157. package/dist/shared/wire/LambderCrashDetail.js +12 -15
  158. package/dist/shared/wire/LambderDefaultApiPath.d.ts +6 -0
  159. package/dist/shared/wire/LambderDefaultApiPath.js +6 -0
  160. package/dist/shared/wire/LambderHttpStatus.d.ts +6 -7
  161. package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +89 -0
  162. package/dist/shared/wire/LambderIdempotencyKeyScope.js +146 -0
  163. package/dist/shared/wire/LambderInvokeApiId.d.ts +27 -0
  164. package/dist/shared/wire/LambderInvokeApiId.js +27 -0
  165. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +6 -7
  166. package/dist/shared/wire/LambderOutcomeAssertions.js +6 -7
  167. package/dist/shared/wire/LambderRequestPayload.d.ts +18 -20
  168. package/dist/shared/wire/LambderRequestPayload.js +4 -6
  169. package/dist/stores/LambderCacheFiller.d.ts +48 -0
  170. package/dist/stores/LambderCacheFiller.js +119 -0
  171. package/dist/stores/LambderCacheKeys.d.ts +26 -0
  172. package/dist/stores/LambderCacheKeys.js +54 -0
  173. package/dist/stores/LambderCacheValues.d.ts +45 -0
  174. package/dist/stores/LambderCacheValues.js +74 -0
  175. package/dist/stores/LambderDdbCache.d.ts +121 -56
  176. package/dist/stores/LambderDdbCache.js +528 -225
  177. package/dist/stores/LambderDdbIdempotencyStore.d.ts +33 -22
  178. package/dist/stores/LambderDdbIdempotencyStore.js +75 -50
  179. package/dist/stores/LambderDdbRateLimiter.d.ts +76 -20
  180. package/dist/stores/LambderDdbRateLimiter.js +151 -39
  181. package/dist/stores/LambderDdbSdk.d.ts +43 -31
  182. package/dist/stores/LambderDdbSdk.js +79 -33
  183. package/dist/stores/LambderDdbSessionStore.d.ts +27 -14
  184. package/dist/stores/LambderDdbSessionStore.js +119 -47
  185. package/dist/stores/LambderHttpFileSource.d.ts +15 -6
  186. package/dist/stores/LambderHttpFileSource.js +15 -13
  187. package/dist/stores/LambderMemoryCache.d.ts +49 -0
  188. package/dist/stores/LambderMemoryCache.js +113 -0
  189. package/dist/stores/LambderMemoryIdempotencyStore.d.ts +13 -12
  190. package/dist/stores/LambderMemoryIdempotencyStore.js +31 -30
  191. package/dist/stores/LambderMemoryRateLimiter.d.ts +8 -9
  192. package/dist/stores/LambderMemoryRateLimiter.js +14 -13
  193. package/dist/stores/LambderMemorySessionStore.d.ts +14 -11
  194. package/dist/stores/LambderMemorySessionStore.js +38 -19
  195. package/dist/stores/LambderS3FileSource.d.ts +21 -6
  196. package/dist/stores/LambderS3FileSource.js +12 -7
  197. package/dist/testing/LambderTestApp.d.ts +21 -23
  198. package/dist/testing/LambderTestApp.js +22 -24
  199. package/dist/testing/LambderTestVisitor.d.ts +10 -12
  200. package/dist/testing/LambderTestVisitor.js +15 -15
  201. package/dist/testing.d.ts +1 -0
  202. package/dist/testing.js +1 -0
  203. package/package.json +12 -3
  204. package/dist/api/LambderApiPolicyEngine.d.ts +0 -47
  205. package/dist/api/LambderApiPolicyEngine.js +0 -85
  206. package/dist/shared/util/LambderKeyFields.d.ts +0 -32
  207. 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,15 +57,15 @@ 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, to export your API
61
+ * types to the frontend.
58
62
  *
59
63
  * Export it as an interface extending LambderFlattenContract, not as a
60
64
  * 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.
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.
65
69
  *
66
70
  * @example
67
71
  * ```typescript
@@ -93,17 +97,24 @@ export default class Lambder {
93
97
  requireSessionApiGuards;
94
98
  /** Told what a request threw, beside whatever answers it; null outside a test. See LAMBDER_CRASH_WATCH. */
95
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;
96
104
  trustedClientIpHeaders;
105
+ trustedHostHeaders;
97
106
  requirePublicApiGuards;
98
107
  constructor(options = {}) {
99
108
  assertCreateOptions(options);
100
109
  this.files = options.files ? new LambderFiles(options.files) : null;
101
- this.apiPath = options.apiPath ?? "/api";
110
+ this.apiPath = options.apiPath ?? DEFAULT_API_PATH;
102
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);
103
116
  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),
117
+ compression: { v1: options.compression === undefined ? null : compression, v2: compression },
107
118
  etag: options.etag ?? DEFAULT_FINALIZE_OPTIONS.etag,
108
119
  maxResponseBytes: options.maxResponseBytes ?? DEFAULT_FINALIZE_OPTIONS.maxResponseBytes,
109
120
  };
@@ -140,8 +151,34 @@ export default class Lambder {
140
151
  idempotency: options.idempotency,
141
152
  });
142
153
  this.trustedClientIpHeaders = options.trustedClientIpHeaders ?? [];
154
+ this.trustedHostHeaders = options.trustedHostHeaders ?? [];
143
155
  this.requireSessionApiGuards = options.requireSessionApiGuards ?? false;
144
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
+ };
145
182
  }
146
183
  // =====================================================================
147
184
  // Registration
@@ -163,7 +200,13 @@ export default class Lambder {
163
200
  this.globalErrorHandler = globalErrorHandler;
164
201
  return this;
165
202
  }
166
- /** 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
+ */
167
210
  setSessionExpiredRouteHandler(handler) {
168
211
  this.sessionExpiredRouteHandler = handler;
169
212
  return this;
@@ -173,10 +216,10 @@ export default class Lambder {
173
216
  * never shadow routes registered after it. Serves files from the `files`
174
217
  * source configured at creation, under the reader's path rule, mime-typed,
175
218
  * 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).
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).
180
223
  */
181
224
  servePublicFiles(options = {}) {
182
225
  if (!this.files)
@@ -218,29 +261,51 @@ export default class Lambder {
218
261
  }
219
262
  // Typed API with Zod
220
263
  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
- });
264
+ this.registerApi(name, "public", schema, handler);
228
265
  return this;
229
266
  }
230
267
  // Typed Session API with Zod
231
268
  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 };
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);
234
300
  this.apiDefinitions.set(name, definition);
235
301
  this.actionList.push({
236
302
  match: (ctx) => ctx.apiName === name ? {} : false,
237
- actionFn: (ctx, resolver) => this.runApi(ctx, resolver, definition, handler),
303
+ actionFn: (ctx) => this.runApi(ctx, definition, handler),
238
304
  });
239
- return this;
240
305
  }
241
306
  addHook(hookEvent, hookFn, priority = 0) {
242
307
  if (hookEvent === "created") {
243
- // Runs once, lazily, at the first render() call.
308
+ // Runs once, lazily, before the first request or event is handled.
244
309
  this.createdHooks.push(hookFn);
245
310
  }
246
311
  else {
@@ -272,11 +337,10 @@ export default class Lambder {
272
337
  // The policy generics are `any` in the plugin signature on purpose: a
273
338
  // module may annotate its parameter as the bare Lambder<SessionData> or
274
339
  // 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.
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.
280
344
  use(plugin) {
281
345
  return plugin(this);
282
346
  }
@@ -285,9 +349,11 @@ export default class Lambder {
285
349
  // What a handler or an app asks the instance for.
286
350
  // =====================================================================
287
351
  /**
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.
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.
291
357
  */
292
358
  getSessionController(ctx) {
293
359
  const context = ctx;
@@ -320,26 +386,24 @@ export default class Lambder {
320
386
  /**
321
387
  * Every registered endpoint's signature, keyed by its hashed name: the
322
388
  * 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.
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.
329
394
  */
330
395
  async apiSignatures() {
331
396
  return Object.fromEntries((await this.apiSignatureEntries()).map(({ key, signature }) => [key, signature]));
332
397
  }
333
398
  /**
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.
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.
339
403
  *
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.
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.
343
407
  */
344
408
  async apiSignatureEntries() {
345
409
  const entries = await Promise.all([...this.apiDefinitions.values()].map(async (definition) => ({
@@ -392,32 +456,35 @@ export default class Lambder {
392
456
  })();
393
457
  this.initPromise = pending;
394
458
  // 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.
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.
400
464
  pending.catch(() => { if (this.initPromise === pending)
401
465
  this.initPromise = null; });
402
466
  }
403
467
  return this.initPromise;
404
468
  }
405
- applyCors(ctx, response, isPreflight) {
406
- applyCorsHeaders(this.corsConfig, ctx, response, isPreflight);
469
+ applyCors(allowedOrigin, response, isPreflight) {
470
+ applyCorsHeaders(this.corsConfig, allowedOrigin, response, isPreflight);
407
471
  }
408
472
  /**
409
473
  * The beforeRender hooks, in priority order: the replaced context to
410
474
  * continue with, or the response one of them answered with.
411
475
  *
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.
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.
419
486
  */
420
- async runBeforeRenderHooks(ctx, resolver) {
487
+ async runBeforeRenderHooks(ctx, resolver, onContextReplaced) {
421
488
  let currentCtx = ctx;
422
489
  for (const hook of this.hookList["beforeRender"]) {
423
490
  const hookResult = await hook.hookFn(currentCtx, resolver);
@@ -425,14 +492,20 @@ export default class Lambder {
425
492
  throw hookResult;
426
493
  if (hookResult instanceof LambderResponse)
427
494
  return hookResult;
428
- 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);
429
502
  }
430
503
  return currentCtx;
431
504
  }
432
- async handleNoMatchedAction(ctx, resolver) {
505
+ async handleNoMatchedAction(ctx, resolver, onContextReplaced) {
433
506
  // Before the fallback hooks, and with the same power it has on a
434
507
  // matched route: the fallback hooks are typed void and cannot answer.
435
- const beforeRenderResult = await this.runBeforeRenderHooks(ctx, resolver);
508
+ const beforeRenderResult = await this.runBeforeRenderHooks(ctx, resolver, onContextReplaced);
436
509
  if (beforeRenderResult instanceof LambderResponse)
437
510
  return beforeRenderResult;
438
511
  const currentCtx = beforeRenderResult;
@@ -443,7 +516,7 @@ export default class Lambder {
443
516
  if (isAPI) {
444
517
  if (this.apiFallbackHandler)
445
518
  return await this.apiFallbackHandler(currentCtx, resolver);
446
- 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));
447
520
  }
448
521
  if (this.publicFilesHandler) {
449
522
  const fileResponse = await this.publicFilesHandler.handle(currentCtx);
@@ -459,16 +532,16 @@ export default class Lambder {
459
532
  }
460
533
  /**
461
534
  * 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.
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.
467
540
  */
468
541
  isCorsPreflight(ctx) {
469
542
  return ctx.method === "OPTIONS" && !!this.corsConfig;
470
543
  }
471
- async resolveRequest(ctx, resolver) {
544
+ async resolveRequest(ctx, resolver, onContextReplaced) {
472
545
  if (this.isCorsPreflight(ctx))
473
546
  return new LambderResponse({ statusCode: 204, body: null });
474
547
  if (ctx.api) {
@@ -496,44 +569,51 @@ export default class Lambder {
496
569
  }
497
570
  }
498
571
  if (!matched)
499
- return await this.handleNoMatchedAction(ctx, resolver);
572
+ return await this.handleNoMatchedAction(ctx, resolver, onContextReplaced);
500
573
  // Set before the hooks run, so a beforeRender hook on a matched route
501
574
  // sees the route's own path params.
502
575
  ctx.pathParams = matched.params;
503
- const beforeRenderResult = await this.runBeforeRenderHooks(ctx, resolver);
576
+ const beforeRenderResult = await this.runBeforeRenderHooks(ctx, resolver, onContextReplaced);
504
577
  if (beforeRenderResult instanceof LambderResponse)
505
578
  return beforeRenderResult;
506
579
  return await matched.action.actionFn(beforeRenderResult, resolver);
507
580
  }
508
581
  async render(event, lambdaContext) {
509
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;
510
587
  try {
511
588
  await this.ensureInitialized();
512
- 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);
513
597
  const resolver = this.getResolver(ctx);
514
598
  let response;
515
599
  try {
516
- 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; });
517
606
  }
518
607
  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
- }
608
+ response = await this.answerThrown(err, ctx, resolver);
531
609
  }
532
- // 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
533
614
  // 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.
615
+ // wrote. Applied afterwards, it would put the handler's value
616
+ // straight back over the hook's.
537
617
  const responseIntoHooks = response;
538
618
  const headersAppliedIntoHooks = ctx.responseHeaders.size;
539
619
  ctx.responseHeaders.applyTo(response);
@@ -542,143 +622,183 @@ export default class Lambder {
542
622
  const hookResponse = await hook.hookFn(ctx, resolver, response);
543
623
  if (hookResponse instanceof Error)
544
624
  throw hookResponse;
545
- 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();
546
630
  }
547
631
  }
548
632
  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
- }
633
+ response = (await this.answerThrown(err, ctx, resolver)).copy();
558
634
  }
559
635
  // 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.
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.
565
641
  ctx.responseHeaders.applyTo(response, response === responseIntoHooks ? headersAppliedIntoHooks : 0);
566
- this.applyCors(ctx, response, this.isCorsPreflight(ctx));
642
+ this.applyCors(allowedOrigin, response, this.isCorsPreflight(ctx));
567
643
  return await finalizeResponse(ctx, response, this.finalizeOptions, ctx.eventFormat);
568
644
  }
569
645
  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
- }
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);
596
698
  }
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 */ }
699
+ }
700
+ catch (handlerErr) {
701
+ if (handlerErr instanceof LambderResponse) {
702
+ try {
703
+ return await finalizeResponse(ctx, this.withCallHeaders(ctx, allowedOrigin, handlerErr), this.finalizeOptions, eventFormat);
606
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);
607
715
  }
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
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;
626
739
  }
627
740
  /**
628
741
  * 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.
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.
632
745
  */
633
746
  async requireSession(ctx, resolver) {
634
747
  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.");
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;
640
771
  }
641
772
  }
642
- /** 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
+ */
643
778
  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);
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
+ }
648
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;
649
796
  }
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
797
  }
655
798
  // =====================================================================
656
799
  // The API path
657
800
  // The steps only an API call takes, around the shared core.
658
801
  // =====================================================================
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
802
  /**
683
803
  * The answer for a rejected input: the app's
684
804
  * setApiInputValidationErrorHandler when set, otherwise the standard 422
@@ -699,12 +819,14 @@ export default class Lambder {
699
819
  * via res.die.*) becomes the answer the pipeline stores and hands back.
700
820
  * The context is the pipeline's context, so a session it fetched is on
701
821
  * ctx.session and the validated payload is on ctx.apiPayload when the
702
- * 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.
703
824
  */
704
- async runApi(ctx, resolver, definition, handler) {
825
+ async runApi(ctx, definition, handler) {
705
826
  const request = ctx.api;
706
827
  if (!request)
707
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 });
708
830
  // What the handler produced, in both forms: the answer went to the
709
831
  // pipeline, and the response is kept so it can carry on unchanged.
710
832
  const handled = { output: null };
@@ -725,13 +847,13 @@ export default class Lambder {
725
847
  handled.output = { response, answer: answerFromResponse(response) };
726
848
  return handled.output.answer;
727
849
  });
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.
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.
735
857
  if (handled.output?.answer === answer)
736
858
  return handled.output.response;
737
859
  return responseFromAnswer(answer);
@@ -743,11 +865,10 @@ export default class Lambder {
743
865
  }
744
866
  /**
745
867
  * 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.
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.
751
872
  *
752
873
  * ```typescript
753
874
  * // app.ts (imports no api modules, so modules can import the type back)
@@ -768,15 +889,14 @@ export default class Lambder {
768
889
  * export const handler = lambder.getHandler();
769
890
  * ```
770
891
  *
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.
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.
778
897
  */
779
898
  export const initLambder = () => ({
899
+ ...policyBuildersFor(),
780
900
  create(options) {
781
901
  return new Lambder(options);
782
902
  },