lambder 6.0.2 → 7.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 (195) hide show
  1. package/CHANGELOG.md +2316 -0
  2. package/README.md +60 -33
  3. package/dist/api/LambderApiAnswer.d.ts +40 -0
  4. package/dist/api/LambderApiAnswer.js +19 -0
  5. package/dist/api/LambderApiCallContext.d.ts +38 -0
  6. package/dist/api/LambderApiCallContext.js +13 -0
  7. package/dist/api/LambderApiDefinition.d.ts +18 -0
  8. package/dist/api/LambderApiDefinition.js +1 -0
  9. package/dist/api/LambderApiEnvelope.d.ts +67 -0
  10. package/dist/api/LambderApiEnvelope.js +180 -0
  11. package/dist/api/LambderApiGuards.d.ts +302 -0
  12. package/dist/api/LambderApiGuards.js +134 -0
  13. package/dist/api/LambderApiIdempotency.d.ts +122 -0
  14. package/dist/api/LambderApiIdempotency.js +330 -0
  15. package/dist/api/LambderApiPipeline.d.ts +134 -0
  16. package/dist/api/LambderApiPipeline.js +221 -0
  17. package/dist/api/LambderApiPolicyEngine.d.ts +36 -0
  18. package/dist/api/LambderApiPolicyEngine.js +77 -0
  19. package/dist/api/LambderApiRateLimits.d.ts +206 -0
  20. package/dist/api/LambderApiRateLimits.js +239 -0
  21. package/dist/api/LambderApiRequest.d.ts +101 -0
  22. package/dist/api/LambderApiRequest.js +129 -0
  23. package/dist/api/LambderApiValidationRefusal.d.ts +32 -0
  24. package/dist/api/LambderApiValidationRefusal.js +40 -0
  25. package/dist/client/LambderCaller.d.ts +62 -55
  26. package/dist/client/LambderCaller.js +147 -90
  27. package/dist/client/lambderFetchTransport.d.ts +9 -0
  28. package/dist/client/lambderFetchTransport.js +71 -0
  29. package/dist/client.d.ts +20 -10
  30. package/dist/client.js +11 -5
  31. package/dist/core/Lambder.d.ts +117 -253
  32. package/dist/core/Lambder.js +374 -341
  33. package/dist/core/LambderContext.d.ts +54 -44
  34. package/dist/core/LambderContext.js +41 -110
  35. package/dist/core/LambderCreateOptions.d.ts +285 -0
  36. package/dist/core/LambderCreateOptions.js +44 -0
  37. package/dist/core/LambderFiles.d.ts +1 -45
  38. package/dist/core/LambderFiles.js +18 -38
  39. package/dist/core/LambderIndexHtml.d.ts +37 -0
  40. package/dist/core/LambderIndexHtml.js +87 -0
  41. package/dist/core/LambderPolicyBuilders.d.ts +17 -0
  42. package/dist/core/LambderPolicyBuilders.js +16 -0
  43. package/dist/core/LambderPublicFiles.d.ts +5 -2
  44. package/dist/core/LambderPublicFiles.js +7 -2
  45. package/dist/core/LambderResolver.d.ts +8 -6
  46. package/dist/core/LambderResponse.d.ts +29 -11
  47. package/dist/core/LambderResponse.js +96 -49
  48. package/dist/core/LambderResponseBuilder.d.ts +18 -14
  49. package/dist/core/LambderResponseBuilder.js +19 -25
  50. package/dist/core/LambderRouting.d.ts +18 -7
  51. package/dist/core/LambderRouting.js +17 -7
  52. package/dist/core/LambderTemplatingEngine.d.ts +0 -62
  53. package/dist/core/LambderTemplatingEngine.js +7 -3
  54. package/dist/index.d.ts +85 -32
  55. package/dist/index.js +44 -16
  56. package/dist/invoke/LambderInvokeCaller.d.ts +46 -139
  57. package/dist/invoke/LambderInvokeCaller.js +140 -335
  58. package/dist/invoke/LambderInvokeOutcome.d.ts +165 -0
  59. package/dist/invoke/LambderInvokeOutcome.js +129 -0
  60. package/dist/invoke/LambderLambdaEvent.d.ts +81 -0
  61. package/dist/invoke/LambderLambdaEvent.js +187 -0
  62. package/dist/invoke/lambderHandlerTransport.d.ts +36 -0
  63. package/dist/invoke/lambderHandlerTransport.js +89 -0
  64. package/dist/mock/LambderMockApp.d.ts +352 -0
  65. package/dist/mock/LambderMockApp.js +815 -0
  66. package/dist/mock/LambderMockBrowserCookies.d.ts +55 -0
  67. package/dist/mock/LambderMockBrowserCookies.js +76 -0
  68. package/dist/mock/LambderMockCallRecorder.d.ts +85 -0
  69. package/dist/mock/LambderMockCallRecorder.js +183 -0
  70. package/dist/mock/LambderMockCreateOptions.d.ts +161 -0
  71. package/dist/mock/LambderMockCreateOptions.js +9 -0
  72. package/dist/mock/LambderMockEntryRegistry.d.ts +52 -0
  73. package/dist/mock/LambderMockEntryRegistry.js +126 -0
  74. package/dist/mock/LambderMockFailureInjector.d.ts +60 -0
  75. package/dist/mock/LambderMockFailureInjector.js +138 -0
  76. package/dist/mock/LambderMockTypes.d.ts +421 -0
  77. package/dist/mock/LambderMockTypes.js +8 -0
  78. package/dist/mock/lambderMockConsoleLogger.d.ts +16 -0
  79. package/dist/mock/lambderMockConsoleLogger.js +35 -0
  80. package/dist/mock/lambderMockInvokeTransport.d.ts +50 -0
  81. package/dist/mock/lambderMockInvokeTransport.js +52 -0
  82. package/dist/mock/lambderMockMswHandler.d.ts +99 -0
  83. package/dist/mock/lambderMockMswHandler.js +126 -0
  84. package/dist/mock.d.ts +34 -0
  85. package/dist/mock.js +27 -0
  86. package/dist/session/LambderSessionController.d.ts +199 -30
  87. package/dist/session/LambderSessionController.js +396 -82
  88. package/dist/session/LambderSessionCrypto.d.ts +66 -0
  89. package/dist/session/LambderSessionCrypto.js +101 -0
  90. package/dist/session/LambderSessionManager.d.ts +118 -80
  91. package/dist/session/LambderSessionManager.js +212 -184
  92. package/dist/shared/LambderI18n.d.ts +6 -6
  93. package/dist/shared/LambderI18n.js +1 -1
  94. package/dist/shared/contracts/LambderFileSource.d.ts +33 -0
  95. package/dist/shared/contracts/LambderFileSource.js +19 -0
  96. package/dist/shared/contracts/LambderIdempotencyStore.d.ts +66 -0
  97. package/dist/shared/contracts/LambderIdempotencyStore.js +12 -0
  98. package/dist/shared/contracts/LambderRateLimiter.d.ts +71 -0
  99. package/dist/shared/contracts/LambderRateLimiter.js +24 -0
  100. package/dist/shared/contracts/LambderSessionStore.d.ts +72 -0
  101. package/dist/shared/contracts/LambderSessionStore.js +13 -0
  102. package/dist/shared/transport/LambderApiTransport.d.ts +139 -0
  103. package/dist/shared/transport/LambderApiTransport.js +65 -0
  104. package/dist/shared/transport/LambderCookieJar.d.ts +121 -0
  105. package/dist/shared/transport/LambderCookieJar.js +246 -0
  106. package/dist/shared/transport/lambderCookieJarTransport.d.ts +30 -0
  107. package/dist/shared/transport/lambderCookieJarTransport.js +60 -0
  108. package/dist/shared/util/LambderBase64.d.ts +10 -0
  109. package/dist/shared/util/LambderBase64.js +27 -0
  110. package/dist/shared/util/LambderCallAbort.d.ts +62 -0
  111. package/dist/shared/util/LambderCallAbort.js +80 -0
  112. package/dist/shared/util/LambderClientIp.d.ts +32 -0
  113. package/dist/shared/util/LambderClientIp.js +56 -0
  114. package/dist/shared/util/LambderExpiringMap.d.ts +119 -0
  115. package/dist/shared/util/LambderExpiringMap.js +217 -0
  116. package/dist/shared/util/LambderKeyFields.d.ts +32 -0
  117. package/dist/shared/util/LambderKeyFields.js +34 -0
  118. package/dist/shared/util/LambderNodeModules.d.ts +9 -0
  119. package/dist/shared/util/LambderNodeModules.js +39 -0
  120. package/dist/shared/util/LambderOptionChecks.d.ts +17 -0
  121. package/dist/shared/util/LambderOptionChecks.js +33 -0
  122. package/dist/shared/util/LambderResponseBrand.d.ts +20 -0
  123. package/dist/shared/util/LambderResponseBrand.js +18 -0
  124. package/dist/shared/util/LambderTextDigest.d.ts +17 -0
  125. package/dist/shared/util/LambderTextDigest.js +34 -0
  126. package/dist/shared/util/LambderTypeUtilities.d.ts +33 -0
  127. package/dist/shared/util/LambderTypeUtilities.js +8 -0
  128. package/dist/shared/wire/LambderAnswerHeaders.d.ts +60 -0
  129. package/dist/shared/wire/LambderAnswerHeaders.js +94 -0
  130. package/dist/shared/wire/LambderApiContract.d.ts +129 -0
  131. package/dist/shared/wire/LambderApiOptionValues.d.ts +39 -0
  132. package/dist/shared/wire/LambderApiOptionValues.js +11 -0
  133. package/dist/shared/wire/LambderApiOutcome.d.ts +128 -0
  134. package/dist/shared/{LambderApiOutcome.js → wire/LambderApiOutcome.js} +16 -9
  135. package/dist/shared/{LambderApiError.d.ts → wire/LambderApiRefusal.d.ts} +48 -26
  136. package/dist/shared/{LambderApiError.js → wire/LambderApiRefusal.js} +13 -11
  137. package/dist/shared/wire/LambderCallOptions.d.ts +171 -0
  138. package/dist/shared/wire/LambderCallOptions.js +17 -0
  139. package/dist/shared/{LambderCompressionCodec.d.ts → wire/LambderCompressionCodec.d.ts} +10 -6
  140. package/dist/shared/{LambderCompressionCodec.js → wire/LambderCompressionCodec.js} +67 -23
  141. package/dist/shared/{LambderCompressionOption.d.ts → wire/LambderCompressionOption.d.ts} +1 -1
  142. package/dist/shared/{LambderCompressionOption.js → wire/LambderCompressionOption.js} +3 -4
  143. package/dist/shared/{LambderCrashDetail.d.ts → wire/LambderCrashDetail.d.ts} +10 -0
  144. package/dist/shared/{LambderCrashDetail.js → wire/LambderCrashDetail.js} +30 -0
  145. package/dist/shared/wire/LambderHttpStatus.d.ts +12 -0
  146. package/dist/shared/wire/LambderHttpStatus.js +1 -0
  147. package/dist/shared/{LambderRequestPayload.d.ts → wire/LambderRequestPayload.d.ts} +25 -17
  148. package/dist/shared/{LambderRequestPayload.js → wire/LambderRequestPayload.js} +29 -52
  149. package/dist/shared/wire/LambderSessionCookieNames.d.ts +9 -0
  150. package/dist/shared/wire/LambderSessionCookieNames.js +9 -0
  151. package/dist/stores/LambderDdbCache.d.ts +12 -9
  152. package/dist/stores/LambderDdbCache.js +56 -47
  153. package/dist/stores/{LambderDdbIdempotency.d.ts → LambderDdbIdempotencyStore.d.ts} +41 -31
  154. package/dist/stores/LambderDdbIdempotencyStore.js +319 -0
  155. package/dist/stores/LambderDdbRateLimiter.d.ts +30 -49
  156. package/dist/stores/LambderDdbRateLimiter.js +47 -45
  157. package/dist/stores/LambderDdbSdk.d.ts +83 -6
  158. package/dist/stores/LambderDdbSdk.js +83 -2
  159. package/dist/stores/LambderDdbSessionStore.d.ts +65 -0
  160. package/dist/stores/LambderDdbSessionStore.js +161 -0
  161. package/dist/stores/LambderHttpFileSource.d.ts +1 -1
  162. package/dist/stores/LambderHttpFileSource.js +10 -1
  163. package/dist/stores/LambderLocalFileSource.d.ts +15 -0
  164. package/dist/stores/LambderLocalFileSource.js +28 -0
  165. package/dist/stores/LambderMemoryIdempotencyStore.d.ts +63 -0
  166. package/dist/stores/LambderMemoryIdempotencyStore.js +113 -0
  167. package/dist/stores/LambderMemoryRateLimiter.d.ts +34 -0
  168. package/dist/stores/LambderMemoryRateLimiter.js +64 -0
  169. package/dist/stores/LambderMemorySessionStore.d.ts +48 -0
  170. package/dist/stores/LambderMemorySessionStore.js +74 -0
  171. package/dist/stores/LambderS3FileSource.d.ts +1 -1
  172. package/dist/stores/LambderS3FileSource.js +1 -1
  173. package/package.json +21 -19
  174. package/dist/client/LambderMSW.d.ts +0 -69
  175. package/dist/client/LambderMSW.js +0 -121
  176. package/dist/policies/LambderApiGuards.d.ts +0 -256
  177. package/dist/policies/LambderApiGuards.js +0 -94
  178. package/dist/policies/LambderApiIdempotency.d.ts +0 -58
  179. package/dist/policies/LambderApiIdempotency.js +0 -219
  180. package/dist/policies/LambderApiPolicies.d.ts +0 -42
  181. package/dist/policies/LambderApiPolicies.js +0 -52
  182. package/dist/policies/LambderApiRateLimits.d.ts +0 -132
  183. package/dist/policies/LambderApiRateLimits.js +0 -119
  184. package/dist/shared/LambderApiContract.d.ts +0 -57
  185. package/dist/shared/LambderApiOutcome.d.ts +0 -69
  186. package/dist/shared/LambderCallOptions.d.ts +0 -71
  187. package/dist/shared/LambderCallOptions.js +0 -16
  188. package/dist/shared/node-polyfills.d.ts +0 -4
  189. package/dist/shared/node-polyfills.js +0 -58
  190. package/dist/stores/LambderDdbIdempotency.js +0 -229
  191. package/dist/testing.d.ts +0 -9
  192. package/dist/testing.js +0 -8
  193. /package/dist/shared/{LambderApiContract.js → wire/LambderApiContract.js} +0 -0
  194. /package/dist/{core → shared/wire}/LambderCookie.d.ts +0 -0
  195. /package/dist/{core → shared/wire}/LambderCookie.js +0 -0
@@ -1,17 +1,20 @@
1
1
  import LambderResolver from "./LambderResolver.js";
2
2
  import LambderResponseBuilder from "./LambderResponseBuilder.js";
3
- import { LambderResponse, finalizeResponse, DEFAULT_FINALIZE_OPTIONS, DEFAULT_RESPONSE_COMPRESSION_SETTINGS, } from "./LambderResponse.js";
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
5
  import { applyCorsHeaders } from "./LambderCors.js";
6
6
  import LambderSessionManager from "../session/LambderSessionManager.js";
7
- import { resolveCompressionOption } from "../shared/LambderCompressionOption.js";
8
- import LambderSessionController from "../session/LambderSessionController.js";
7
+ import { resolveCompressionOption } from "../shared/wire/LambderCompressionOption.js";
9
8
  import { LambderPublicFilesHandler } from "./LambderPublicFiles.js";
9
+ import { LambderIndexHtmlHandler } from "./LambderIndexHtml.js";
10
10
  import { LambderFiles } from "./LambderFiles.js";
11
- import { isLambderApiError, LAMBDER_REFUSAL_CODES } from "../shared/LambderApiError.js";
12
- import { LambderApiPolicyEngine } from "../policies/LambderApiPolicies.js";
13
- import { createContext, isV2HttpEvent, restoreCompressedApiPayload } from "./LambderContext.js";
14
- import { DEFAULT_MAX_RESTORED_PAYLOAD_BYTES } from "../shared/LambderRequestPayload.js";
11
+ import { isLambderApiRefusal } from "../shared/wire/LambderApiRefusal.js";
12
+ import { LambderApiPipeline } from "../api/LambderApiPipeline.js";
13
+ import { apiNotFoundAnswer, crashAnswer, refusalAnswer, } from "../api/LambderApiEnvelope.js";
14
+ import { createContext, isV2HttpEvent } from "./LambderContext.js";
15
+ import { COMPRESSED_PAYLOAD_GZ_FIELD, COMPRESSED_PAYLOAD_BR_FIELD, COMPRESSED_PAYLOAD_BYTES_FIELD } from "../shared/wire/LambderRequestPayload.js";
16
+ import { coerceToError } from "../shared/wire/LambderCrashDetail.js";
17
+ import { assertCreateOptions, } from "./LambderCreateOptions.js";
15
18
  /**
16
19
  * Main Lambder class for building type-safe serverless APIs. Create
17
20
  * instances with initLambder<SessionData>().create({...}) (see below): the
@@ -25,6 +28,7 @@ import { DEFAULT_MAX_RESTORED_PAYLOAD_BYTES } from "../shared/LambderRequestPayl
25
28
  * @typeParam _TIdempotencyEnabled - @internal True when create() received idempotency (do not pass manually)
26
29
  * @typeParam _TSessionGuardsRequired - @internal True when create() received requireSessionApiGuards (do not pass manually)
27
30
  * @typeParam _TPublicGuardsRequired - @internal True when create() received requirePublicApiGuards (do not pass manually)
31
+ * @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.
28
32
  *
29
33
  * @example
30
34
  * ```typescript
@@ -36,6 +40,10 @@ import { DEFAULT_MAX_RESTORED_PAYLOAD_BYTES } from "../shared/LambderRequestPayl
36
40
  * ```
37
41
  */
38
42
  export default class Lambder {
43
+ // =====================================================================
44
+ // Construction
45
+ // Everything an instance is, fixed before the first registration.
46
+ // =====================================================================
39
47
  apiPath;
40
48
  apiVersion;
41
49
  /** The instance's file reader (source + caches), or null without the files option. */
@@ -52,7 +60,8 @@ export default class Lambder {
52
60
  */
53
61
  ApiContract;
54
62
  actionList = [];
55
- apiPolicyEngine = null;
63
+ /** The API core: the pipeline every API call runs through, shared in shape with the mock runtime. */
64
+ pipeline;
56
65
  registeredApiNames = new Set();
57
66
  hookList = { "beforeRender": [], "afterRender": [], "fallback": [] };
58
67
  createdHooks = [];
@@ -63,18 +72,15 @@ export default class Lambder {
63
72
  apiInputValidationErrorHandler = null;
64
73
  sessionExpiredRouteHandler = null;
65
74
  publicFilesHandler = null;
66
- indexHtmlConfig = null;
75
+ indexHtmlHandler = null;
67
76
  eventActionList = [];
68
77
  corsConfig = null;
69
78
  finalizeOptions;
70
- maxRequestPayloadBytes;
71
79
  requireSessionApiGuards;
80
+ trustedClientIpHeaders;
72
81
  requirePublicApiGuards;
73
- lambderSessionManager;
74
- sessionCookieOptions = {};
75
- sessionTokenCookieKey = "LMDRSESSIONTKID";
76
- sessionCsrfCookieKey = "LMDRSESSIONCSTK";
77
82
  constructor(options = {}) {
83
+ assertCreateOptions(options);
78
84
  this.files = options.files ? new LambderFiles(options.files) : null;
79
85
  this.apiPath = options.apiPath ?? "/api";
80
86
  this.apiVersion = options.apiVersion ?? null;
@@ -85,45 +91,43 @@ export default class Lambder {
85
91
  etag: options.etag ?? DEFAULT_FINALIZE_OPTIONS.etag,
86
92
  maxResponseBytes: options.maxResponseBytes ?? DEFAULT_FINALIZE_OPTIONS.maxResponseBytes,
87
93
  };
88
- this.maxRequestPayloadBytes = options.maxRequestPayloadBytes ?? DEFAULT_MAX_RESTORED_PAYLOAD_BYTES;
89
- if (!Number.isSafeInteger(this.maxRequestPayloadBytes) || this.maxRequestPayloadBytes <= 0) {
90
- throw new Error("maxRequestPayloadBytes must be a positive integer");
91
- }
92
94
  if (options.cors !== undefined && options.cors !== false) {
93
95
  this.corsConfig = options.cors === true ? {} : options.cors;
94
96
  }
95
- if (options.session) {
96
- const session = options.session;
97
- this.lambderSessionManager = new LambderSessionManager({
98
- tableName: session.tableName,
99
- tableRegion: session.tableRegion,
100
- partitionKey: session.partitionKey ?? "pk",
101
- sortKey: session.sortKey ?? "sk",
102
- sessionSalt: session.sessionSalt,
103
- enableSlidingExpiration: session.enableSlidingExpiration,
104
- slidingWriteIntervalSeconds: session.slidingWriteIntervalSeconds,
105
- dataRefresh: session.dataRefresh,
106
- compression: session.compression,
107
- });
108
- this.sessionCookieOptions = session.cookie ?? {};
109
- if (session.tokenCookieKey)
110
- this.sessionTokenCookieKey = session.tokenCookieKey;
111
- if (session.csrfCookieKey)
112
- this.sessionCsrfCookieKey = session.csrfCookieKey;
113
- }
114
- if (options.rateLimits)
115
- this.getOrCreatePolicyEngine().setRateLimits(options.rateLimits);
116
- if (options.guards)
117
- this.getOrCreatePolicyEngine().addGuards(options.guards);
118
- if (options.idempotency)
119
- this.getOrCreatePolicyEngine().setIdempotency(options.idempotency);
97
+ const session = options.session;
98
+ this.pipeline = new LambderApiPipeline({
99
+ apiVersion: this.apiVersion,
100
+ maxRequestPayloadBytes: options.maxRequestPayloadBytes,
101
+ // The app's own validation handler is read at call time, since
102
+ // setApiInputValidationErrorHandler runs after creation.
103
+ onInvalidInput: (zodError, ctx) => this.inputValidationRefusal(ctx, zodError),
104
+ sessions: session
105
+ ? {
106
+ manager: new LambderSessionManager({
107
+ store: session.store,
108
+ sessionSalt: session.sessionSalt,
109
+ enableSlidingExpiration: session.enableSlidingExpiration,
110
+ slidingWriteIntervalSeconds: session.slidingWriteIntervalSeconds,
111
+ dataRefresh: session.dataRefresh,
112
+ crypto: session.crypto,
113
+ }),
114
+ tokenCookieKey: session.tokenCookieKey,
115
+ csrfCookieKey: session.csrfCookieKey,
116
+ cookieOptions: session.cookie,
117
+ }
118
+ : undefined,
119
+ rateLimits: options.rateLimits,
120
+ guards: options.guards,
121
+ idempotency: options.idempotency,
122
+ });
123
+ this.trustedClientIpHeaders = options.trustedClientIpHeaders ?? [];
120
124
  this.requireSessionApiGuards = options.requireSessionApiGuards ?? false;
121
125
  this.requirePublicApiGuards = options.requirePublicApiGuards ?? false;
122
- const requireFlag = this.requireSessionApiGuards ? "requireSessionApiGuards" : "requirePublicApiGuards";
123
- if ((this.requireSessionApiGuards || this.requirePublicApiGuards) && !options.guards) {
124
- throw new Error(`Lambder: ${requireFlag} needs a guards map at creation for APIs to declare from.`);
125
- }
126
126
  }
127
+ // =====================================================================
128
+ // Registration
129
+ // What an app declares on the instance; all of it chains.
130
+ // =====================================================================
127
131
  setRouteFallbackHandler(routeFallbackHandler) {
128
132
  this.routeFallbackHandler = routeFallbackHandler;
129
133
  return this;
@@ -148,15 +152,16 @@ export default class Lambder {
148
152
  /**
149
153
  * Terminal public-file layer. Runs only when no route matched, so it can
150
154
  * never shadow routes registered after it. Serves files from the `files`
151
- * source configured at creation, traversal-safe, mime-typed,
155
+ * source configured at creation, under the reader's path rule, mime-typed,
152
156
  * memory-cached, with the immutable-cache heuristic for content-hashed
153
- * assets; when the source has no such file the request falls through to
154
- * setRouteFallbackHandler, where the app decides what remains (e.g.
155
- * render an app shell with res.templateFile).
157
+ * assets. Only configured methods reach it, default GET/HEAD, as for
158
+ * serveIndexHtml; a gated-out method and a path the source has no file
159
+ * for both fall through to setRouteFallbackHandler, where the app decides
160
+ * what remains (e.g. render an app shell with res.templateFile).
156
161
  */
157
162
  servePublicFiles(options = {}) {
158
163
  if (!this.files)
159
- throw new Error("servePublicFiles requires the files option at creation (e.g. files: new LambderLocalFileSource({ root }))");
164
+ throw new Error("Lambder: servePublicFiles requires the files option at creation (e.g. files: new LambderLocalFileSource({ root }))");
160
165
  this.publicFilesHandler = new LambderPublicFilesHandler(this.files, options);
161
166
  return this;
162
167
  }
@@ -170,78 +175,9 @@ export default class Lambder {
170
175
  * res.templateFile (markers optional) with no-cache.
171
176
  */
172
177
  serveIndexHtml(handler, options = {}) {
173
- this.indexHtmlConfig = { handler: handler ?? null, options };
178
+ this.indexHtmlHandler = new LambderIndexHtmlHandler(handler ?? null, options);
174
179
  return this;
175
180
  }
176
- /** Apply the serveIndexHtml gates; null means fall through. */
177
- async tryServeIndexHtml(ctx, resolver) {
178
- if (!this.indexHtmlConfig)
179
- return null;
180
- const { handler, options } = this.indexHtmlConfig;
181
- const methods = (options.methods ?? ["GET", "HEAD"]).map((m) => m.toUpperCase());
182
- if (!methods.includes(ctx.method.toUpperCase()))
183
- return null;
184
- if ((options.skipFilePaths ?? false) && (ctx.path.split("/").pop() ?? "").includes("."))
185
- return null;
186
- if (options.redirectTrailingSlash && ctx.path.length > 1 && ctx.path.endsWith("/")) {
187
- const target = ctx.path.replace(/\/+$/, "") || "/";
188
- return resolver.redirect(target + buildQueryString(ctx), 301);
189
- }
190
- const response = handler
191
- ? await handler(ctx, resolver)
192
- : await resolver.templateFile(typeof options.indexFile === "function" ? options.indexFile(ctx) : (options.indexFile ?? "index.html"), {}, { cacheControl: "no-cache" });
193
- if (options.compress !== undefined) {
194
- response.compress = typeof options.compress === "function" ? options.compress(ctx) : options.compress;
195
- }
196
- return response;
197
- }
198
- getOrCreatePolicyEngine() {
199
- // Late-bound: the handler may be set after creation, so the engine
200
- // asks at request time rather than capturing it here.
201
- if (!this.apiPolicyEngine)
202
- this.apiPolicyEngine = new LambderApiPolicyEngine((ctx, resolver, zodError) => this.inputValidationRefusal(ctx, resolver, zodError));
203
- return this.apiPolicyEngine;
204
- }
205
- /**
206
- * The response for a rejected input: the app's
207
- * setApiInputValidationErrorHandler when set, otherwise the standard 422
208
- * body. The API's own schema and every preflight slice (guard inputs,
209
- * rate-limit keys) answer through here, so one failure has one shape.
210
- */
211
- async inputValidationRefusal(ctx, resolver, zodError) {
212
- if (this.apiInputValidationErrorHandler)
213
- return await this.apiInputValidationErrorHandler(ctx, resolver, zodError);
214
- // Spelled out rather than serialized as-is: zod 4 keeps `issues` as a
215
- // non-enumerable property, so JSON.stringify(zodError) would carry the
216
- // issues only inside the message string, and a client's validation
217
- // handler would receive a ZodError with nothing to branch on.
218
- return resolver.json({
219
- error: "Input validation failed",
220
- zodError: { name: zodError.name, message: zodError.message, issues: zodError.issues },
221
- }, { statusCode: 422 });
222
- }
223
- /** Registration-time checks shared by addApi/addSessionApi. */
224
- assertApiRegistration(name, mode, options) {
225
- if (this.registeredApiNames.has(name)) {
226
- throw new Error(`Lambder: duplicate API name "${name}". Dispatch is first-match, so the second registration would be silently dead code.`);
227
- }
228
- this.registeredApiNames.add(name);
229
- const guardsRequired = mode === "session" ? this.requireSessionApiGuards : this.requirePublicApiGuards;
230
- if (guardsRequired && options.guards === undefined) {
231
- const optOut = mode === "session"
232
- ? "the named no-op guard that marks the session itself as the whole authorization"
233
- : "the named no-op guard that records why anyone may call it";
234
- throw new Error(`Lambder: ${mode} API "${name}" declares no guards, and require${mode === "session" ? "Session" : "Public"}ApiGuards is on. ` +
235
- `Declare the guard that authorizes it, or ${optOut}.`);
236
- }
237
- const usesPolicies = options.rateLimit !== undefined || options.guards !== undefined || options.idempotency !== undefined;
238
- if (!usesPolicies)
239
- return;
240
- if (!this.apiPolicyEngine) {
241
- throw new Error(`Lambder: API "${name}" declares rateLimit/guards/idempotency, but none of rateLimits/guards/idempotency was configured at creation.`);
242
- }
243
- this.apiPolicyEngine.assertRegistration(name, mode, options);
244
- }
245
181
  addRoute(condition, actionFn) {
246
182
  this.actionList.push({
247
183
  match: compileRouteMatcher(condition),
@@ -254,96 +190,33 @@ export default class Lambder {
254
190
  match: compileRouteMatcher(condition),
255
191
  actionFn: async (ctx, resolver) => {
256
192
  await this.requireSession(ctx, resolver);
193
+ // requireSession answered already if there was no session, so
194
+ // the only narrowing left is null to non-null.
257
195
  return await actionFn(ctx, resolver);
258
196
  },
259
197
  });
260
198
  return this;
261
199
  }
262
- // Plugin system
263
- // The policy generics are `any` in the plugin signature on purpose: a
264
- // module may annotate its parameter as the bare Lambder<SessionData> or
265
- // as the app's narrowed alias, and both must chain. Registration-time
266
- // assertions still verify every referenced policy/guard name at runtime.
267
- // Every policy generic must be listed here: one short of the class's
268
- // parameter list and the missing one silently falls back to its default,
269
- // which makes an instance carrying the non-default value unassignable to
270
- // its own plugins (requireSessionApiGuards did exactly that in 4.7.1).
271
- use(plugin) {
272
- return plugin(this);
273
- }
274
200
  // Typed API with Zod
275
201
  addApi(name, schema, handler) {
276
202
  this.assertApiRegistration(name, "public", schema);
203
+ const definition = { name, mode: "public", guards: schema.guards, rateLimit: schema.rateLimit, idempotency: schema.idempotency, input: schema.input };
277
204
  this.actionList.push({
278
205
  match: (ctx) => ctx.apiName === name ? {} : false,
279
- actionFn: async (ctx, resolver) => {
280
- // Replay fast path first: a completed idempotent request must
281
- // answer its stored response without burning rate-limit quota
282
- // or re-running guards (no handler executes either way).
283
- if (this.apiPolicyEngine && schema.idempotency) {
284
- const replay = await this.apiPolicyEngine.findReplay(name, ctx);
285
- if (replay)
286
- return replay;
287
- }
288
- if (this.apiPolicyEngine)
289
- await this.apiPolicyEngine.runPreflight(name, ctx, resolver, schema);
290
- const inputResult = schema.input.safeParse(ctx.apiPayload);
291
- if (!inputResult.success)
292
- return await this.inputValidationRefusal(ctx, resolver, inputResult.error);
293
- ctx.apiPayload = inputResult.data;
294
- const run = async () => await handler(ctx, resolver);
295
- if (this.apiPolicyEngine && schema.idempotency)
296
- return await this.apiPolicyEngine.withIdempotency(name, ctx, schema.idempotency, run);
297
- return await run();
298
- },
206
+ actionFn: (ctx, resolver) => this.runApi(ctx, resolver, definition, handler),
299
207
  });
300
208
  return this;
301
209
  }
302
210
  // Typed Session API with Zod
303
211
  addSessionApi(name, schema, handler) {
304
212
  this.assertApiRegistration(name, "session", schema);
213
+ const definition = { name, mode: "session", guards: schema.guards, rateLimit: schema.rateLimit, idempotency: schema.idempotency, input: schema.input };
305
214
  this.actionList.push({
306
215
  match: (ctx) => ctx.apiName === name ? {} : false,
307
- actionFn: async (ctx, resolver) => {
308
- await this.requireSession(ctx, resolver);
309
- // Replay fast path (after the session fetch: the replay scope
310
- // is keyed per session): see addApi.
311
- if (this.apiPolicyEngine && schema.idempotency) {
312
- const replay = await this.apiPolicyEngine.findReplay(name, ctx);
313
- if (replay)
314
- return replay;
315
- }
316
- if (this.apiPolicyEngine)
317
- await this.apiPolicyEngine.runPreflight(name, ctx, resolver, schema);
318
- const inputResult = schema.input.safeParse(ctx.apiPayload);
319
- if (!inputResult.success)
320
- return await this.inputValidationRefusal(ctx, resolver, inputResult.error);
321
- ctx.apiPayload = inputResult.data;
322
- const run = async () => await handler(ctx, resolver);
323
- if (this.apiPolicyEngine && schema.idempotency)
324
- return await this.apiPolicyEngine.withIdempotency(name, ctx, schema.idempotency, run);
325
- return await run();
326
- }
216
+ actionFn: (ctx, resolver) => this.runApi(ctx, resolver, definition, handler),
327
217
  });
328
218
  return this;
329
219
  }
330
- /**
331
- * Fetch the session or short-circuit the request: API calls get the
332
- * protocol's { sessionExpired: true } response (handled by LambderCaller),
333
- * routes get the sessionExpiredRouteHandler response (default 401).
334
- */
335
- async requireSession(ctx, resolver) {
336
- const session = await this.getSessionController(ctx).fetchSessionIfExists();
337
- if (!session) {
338
- if (ctx._otherInternal.isApiCall) {
339
- throw resolver.api(null, { sessionExpired: true });
340
- }
341
- if (this.sessionExpiredRouteHandler) {
342
- throw await this.sessionExpiredRouteHandler(ctx, resolver);
343
- }
344
- throw resolver.status(401, "Session required.");
345
- }
346
- }
347
220
  addHook(hookEvent, hookFn, priority = 0) {
348
221
  if (hookEvent === "created") {
349
222
  // Runs once, lazily, at the first render() call.
@@ -355,16 +228,53 @@ export default class Lambder {
355
228
  }
356
229
  return this;
357
230
  }
358
- getSessionController(ctx) {
359
- if (!this.lambderSessionManager)
360
- throw new Error("Session is not enabled. Configure the session option at creation.");
361
- return new LambderSessionController({
362
- lambderSessionManager: this.lambderSessionManager,
363
- sessionTokenCookieKey: this.sessionTokenCookieKey,
364
- sessionCsrfCookieKey: this.sessionCsrfCookieKey,
365
- cookieOptions: this.sessionCookieOptions,
366
- ctx,
231
+ addAction(filter, actionFn) {
232
+ // HTTP side: joins the route/API chain in registration order.
233
+ this.actionList.push({
234
+ match: (ctx) => filter(ctx.event, ctx) ? {} : false,
235
+ actionFn: async (ctx, resolver) => {
236
+ const result = await actionFn(ctx.event, { ctx, res: resolver, lambdaContext: ctx.lambdaContext });
237
+ if (!(result instanceof LambderResponse)) {
238
+ throw new Error("Lambder: an addAction matched an HTTP request but did not return a response. Build one with tools.res.");
239
+ }
240
+ return result;
241
+ },
242
+ });
243
+ // Non-HTTP side.
244
+ this.eventActionList.push({
245
+ match: (event) => filter(event, null),
246
+ actionFn: (event, lambdaContext) => actionFn(event, { ctx: null, res: null, lambdaContext }),
367
247
  });
248
+ return this;
249
+ }
250
+ // Plugin system
251
+ // The policy generics are `any` in the plugin signature on purpose: a
252
+ // module may annotate its parameter as the bare Lambder<SessionData> or
253
+ // as the app's narrowed alias, and both must chain. Registration-time
254
+ // assertions still verify every referenced policy/guard name at runtime.
255
+ // Every policy generic must be listed here: one short of the class's
256
+ // parameter list and the missing one silently falls back to its default,
257
+ // which makes an instance carrying the non-default value unassignable to
258
+ // its own plugins.
259
+ use(plugin) {
260
+ return plugin(this);
261
+ }
262
+ // =====================================================================
263
+ // Accessors
264
+ // What a handler or an app asks the instance for.
265
+ // =====================================================================
266
+ /**
267
+ * Sessions for this request: what handlers create, rotate, refresh and
268
+ * end sessions with. An API call presents its posted CSRF token; a route
269
+ * presents cookies alone.
270
+ */
271
+ getSessionController(ctx) {
272
+ const context = ctx;
273
+ return this.pipeline.sessionController(context, context.api ? LambderApiPipeline.sessionInfoOf(context.api) : { host: context.host, cookies: context.cookieList, csrfToken: null });
274
+ }
275
+ /** The session manager, for code that works on sessions outside a request (maintenance, tests). */
276
+ getSessionManager() {
277
+ return this.pipeline.sessionManager;
368
278
  }
369
279
  getResponseBuilder(ctx) {
370
280
  return new LambderResponseBuilder({
@@ -382,25 +292,11 @@ export default class Lambder {
382
292
  });
383
293
  }
384
294
  ;
385
- /** Map a thrown LambderApiError onto the structured API envelope. */
386
- apiErrorResponse(err, resolver) {
387
- return resolver.api(null, {
388
- ...(err.errorMessage !== undefined ? { errorMessage: err.errorMessage } : {}),
389
- ...(err.notAuthorized ? { notAuthorized: true } : {}),
390
- ...(err.sessionExpired ? { sessionExpired: true } : {}),
391
- }, {
392
- ...(err.statusCode !== undefined ? { statusCode: err.statusCode } : {}),
393
- ...(err.headers ? { headers: err.headers } : {}),
394
- });
395
- }
396
295
  getHandler() {
397
296
  return ((event, context) => Lambder.isHttpEvent(event)
398
297
  ? this.render(event, context)
399
298
  : this.renderEvent(event, context));
400
299
  }
401
- // ---------------------------------------------------------------------
402
- // Actions (raw-event or context filtering; the only handler for non-HTTP)
403
- // ---------------------------------------------------------------------
404
300
  /** True when the Lambda event is an API Gateway HTTP event (REST API v1 or HTTP API / Function URL v2). */
405
301
  static isHttpEvent(event) {
406
302
  if (!event || typeof event !== "object")
@@ -409,99 +305,113 @@ export default class Lambder {
409
305
  return true;
410
306
  return isV2HttpEvent(event);
411
307
  }
412
- addAction(filter, actionFn) {
413
- // HTTP side: joins the route/API chain in registration order.
414
- this.actionList.push({
415
- match: (ctx) => filter(ctx.event, ctx) ? {} : false,
416
- actionFn: async (ctx, resolver) => {
417
- const result = await actionFn(ctx.event, { ctx, res: resolver, lambdaContext: ctx.lambdaContext });
418
- if (!(result instanceof LambderResponse)) {
419
- throw new Error("Lambder: an addAction matched an HTTP request but did not return a response. Build one with tools.res.");
420
- }
421
- return result;
422
- },
423
- });
424
- // Non-HTTP side.
425
- this.eventActionList.push({
426
- match: (event) => filter(event, null),
427
- actionFn: (event, lambdaContext) => actionFn(event, { ctx: null, res: null, lambdaContext }),
428
- });
429
- return this;
430
- }
431
- /** Dispatch a non-HTTP Lambda event to the registered actions. */
432
- async renderEvent(event, lambdaContext) {
433
- await this.ensureInitialized();
434
- for (const action of this.eventActionList) {
435
- if (action.match(event)) {
436
- return await action.actionFn(event, lambdaContext);
437
- }
438
- }
439
- const summary = event && typeof event === "object"
440
- ? ` (source: ${String(event.source ?? "?")}, detail-type: ${String(event["detail-type"] ?? "?")})`
441
- : "";
442
- throw new Error(`Lambder: no action matched non-HTTP event${summary}. Register one with addAction(); a trailing addAction(() => true, ...) acts as a fallback.`);
443
- }
444
- // ---------------------------------------------------------------------
445
- // Render pipeline
446
- // ---------------------------------------------------------------------
308
+ // =====================================================================
309
+ // The request path
310
+ // One HTTP invocation, from the event to the finalized response.
311
+ // =====================================================================
447
312
  ensureInitialized() {
448
313
  if (!this.initPromise) {
449
- this.initPromise = (async () => {
314
+ const pending = (async () => {
450
315
  for (const hookFn of this.createdHooks) {
451
316
  await hookFn(this);
452
317
  }
453
318
  })();
319
+ this.initPromise = pending;
320
+ // A `created` hook usually reaches something that can be briefly
321
+ // unavailable (a first DynamoDB read, a secret fetch). Keeping the
322
+ // rejected promise meant the warm container answered every later
323
+ // invocation with the first failure and never recovered, so the
324
+ // failure is forgotten and the next invocation runs the hooks
325
+ // again. Whoever is awaiting this one still gets the rejection.
326
+ pending.catch(() => { if (this.initPromise === pending)
327
+ this.initPromise = null; });
454
328
  }
455
329
  return this.initPromise;
456
330
  }
457
331
  applyCors(ctx, response, isPreflight) {
458
332
  applyCorsHeaders(this.corsConfig, ctx, response, isPreflight);
459
333
  }
334
+ /**
335
+ * The beforeRender hooks, in priority order: the replaced context to
336
+ * continue with, or the response one of them answered with.
337
+ *
338
+ * Its own method because BOTH request paths run it. Left inline after the
339
+ * match, it ran for routes and APIs and for nothing else, so a
340
+ * servePublicFiles or serveIndexHtml answer, which is every asset and
341
+ * every app-shell page, skipped the one hook that can inspect a request,
342
+ * replace its context or short-circuit it: a security header written in a
343
+ * hook reached the API answers and not the HTML it was written for, and a
344
+ * maintenance-mode hook served the whole frontend anyway.
345
+ */
346
+ async runBeforeRenderHooks(ctx, resolver) {
347
+ let currentCtx = ctx;
348
+ for (const hook of this.hookList["beforeRender"]) {
349
+ const hookResult = await hook.hookFn(currentCtx, resolver);
350
+ if (hookResult instanceof Error)
351
+ throw hookResult;
352
+ if (hookResult instanceof LambderResponse)
353
+ return hookResult;
354
+ currentCtx = hookResult;
355
+ }
356
+ return currentCtx;
357
+ }
460
358
  async handleNoMatchedAction(ctx, resolver) {
359
+ // Before the fallback hooks, and with the same power it has on a
360
+ // matched route: the fallback hooks are typed void and cannot answer.
361
+ const beforeRenderResult = await this.runBeforeRenderHooks(ctx, resolver);
362
+ if (beforeRenderResult instanceof LambderResponse)
363
+ return beforeRenderResult;
364
+ const currentCtx = beforeRenderResult;
461
365
  for (const hook of this.hookList["fallback"]) {
462
- await hook.hookFn(ctx, resolver);
366
+ await hook.hookFn(currentCtx, resolver);
463
367
  }
464
- const isAPI = ctx._otherInternal.isApiCall || ctx.path === this.apiPath;
368
+ const isAPI = currentCtx.api !== null || currentCtx.path === this.apiPath;
465
369
  if (isAPI) {
466
370
  if (this.apiFallbackHandler)
467
- return await this.apiFallbackHandler(ctx, resolver);
468
- return resolver.api(null, { errorMessage: { type: "warning", code: LAMBDER_REFUSAL_CODES.apiNotFound, content: "API not found." } });
371
+ return await this.apiFallbackHandler(currentCtx, resolver);
372
+ return responseFromAnswer(currentCtx.api ? this.pipeline.answerUnknownApi(currentCtx.api, currentCtx) : apiNotFoundAnswer(this.apiVersion, currentCtx.logList));
469
373
  }
470
374
  if (this.publicFilesHandler) {
471
- const fileResponse = await this.publicFilesHandler.handle(ctx);
375
+ const fileResponse = await this.publicFilesHandler.handle(currentCtx);
472
376
  if (fileResponse)
473
377
  return fileResponse;
474
378
  }
475
- const indexResponse = await this.tryServeIndexHtml(ctx, resolver);
379
+ const indexResponse = this.indexHtmlHandler ? await this.indexHtmlHandler.handle(currentCtx, resolver) : null;
476
380
  if (indexResponse)
477
381
  return indexResponse;
478
382
  if (this.routeFallbackHandler)
479
- return await this.routeFallbackHandler(ctx, resolver);
383
+ return await this.routeFallbackHandler(currentCtx, resolver);
480
384
  return resolver.text("Not found.", { statusCode: 404 });
481
385
  }
386
+ /**
387
+ * True for the OPTIONS request the CORS layer answers by itself. Asked
388
+ * twice: once to build the 204, once at the end of render() to decide
389
+ * which form of the headers goes on. Asking once and letting the tail
390
+ * apply the ordinary headers on top of the 204's would put both forms on
391
+ * a preflight, answering `Vary: Origin, Origin` and an
392
+ * Access-Control-Expose-Headers that means nothing before a request.
393
+ */
394
+ isCorsPreflight(ctx) {
395
+ return ctx.method === "OPTIONS" && !!this.corsConfig;
396
+ }
482
397
  async resolveRequest(ctx, resolver) {
483
- if (ctx.method === "OPTIONS" && this.corsConfig) {
484
- const preflight = new LambderResponse({ statusCode: 204, body: null });
485
- this.applyCors(ctx, preflight, true);
486
- return preflight;
487
- }
488
- // Version check if provided by both the client and the server
489
- if (this.apiVersion && ctx._otherInternal.requestVersion && ctx._otherInternal.requestVersion !== this.apiVersion) {
490
- return resolver.versionExpired();
491
- }
492
- // A gzipped payload is restored before anything reads it: rate-limit
493
- // key slices, guards and input validation all see a plain payload.
494
- if (ctx._otherInternal.isApiCall) {
495
- const restored = await restoreCompressedApiPayload(ctx, this.maxRequestPayloadBytes);
496
- if (!restored.ok) {
497
- return resolver.api(null, {
498
- errorMessage: {
499
- type: "error",
500
- code: LAMBDER_REFUSAL_CODES.invalidRequestPayload,
501
- content: restored.message,
502
- },
503
- }, { statusCode: 400 });
504
- }
398
+ if (this.isCorsPreflight(ctx))
399
+ return new LambderResponse({ statusCode: 204, body: null });
400
+ if (ctx.api) {
401
+ // The protocol's own pre-pass, run here rather than left to the
402
+ // pipeline so that hooks and route matching see a plain payload,
403
+ // and so a stale client is answered before any of them, whether or
404
+ // not the name it asked for exists.
405
+ const prepared = await this.pipeline.prepare(ctx.api);
406
+ if (prepared)
407
+ return responseFromAnswer(prepared);
408
+ // ctx.post is the raw body view; it shows the restored payload and
409
+ // none of the wire fields, so nothing reading it sees the format.
410
+ ctx.apiPayload = ctx.api.payload;
411
+ ctx.post.payload = ctx.api.payload;
412
+ delete ctx.post[COMPRESSED_PAYLOAD_GZ_FIELD];
413
+ delete ctx.post[COMPRESSED_PAYLOAD_BR_FIELD];
414
+ delete ctx.post[COMPRESSED_PAYLOAD_BYTES_FIELD];
505
415
  }
506
416
  let matched = null;
507
417
  for (const action of this.actionList) {
@@ -513,23 +423,19 @@ export default class Lambder {
513
423
  }
514
424
  if (!matched)
515
425
  return await this.handleNoMatchedAction(ctx, resolver);
426
+ // Set before the hooks run, so a beforeRender hook on a matched route
427
+ // sees the route's own path params.
516
428
  ctx.pathParams = matched.params;
517
- let currentCtx = ctx;
518
- for (const hook of this.hookList["beforeRender"]) {
519
- const hookResult = await hook.hookFn(currentCtx, resolver);
520
- if (hookResult instanceof Error)
521
- throw hookResult;
522
- if (hookResult instanceof LambderResponse)
523
- return hookResult;
524
- currentCtx = hookResult;
525
- }
526
- return await matched.action.actionFn(currentCtx, resolver);
429
+ const beforeRenderResult = await this.runBeforeRenderHooks(ctx, resolver);
430
+ if (beforeRenderResult instanceof LambderResponse)
431
+ return beforeRenderResult;
432
+ return await matched.action.actionFn(beforeRenderResult, resolver);
527
433
  }
528
434
  async render(event, lambdaContext) {
529
435
  let ctx = null;
530
436
  try {
531
437
  await this.ensureInitialized();
532
- ctx = createContext(event, lambdaContext, this.apiPath);
438
+ ctx = createContext(event, lambdaContext, this.apiPath, this.trustedClientIpHeaders);
533
439
  const resolver = this.getResolver(ctx);
534
440
  let response;
535
441
  try {
@@ -540,15 +446,23 @@ export default class Lambder {
540
446
  if (err instanceof LambderResponse) {
541
447
  response = err;
542
448
  }
543
- // A thrown LambderApiError on an API call IS a structured refusal
449
+ // A thrown LambderApiRefusal on an API call IS a structured refusal
544
450
  // (brand-checked, not instanceof, to survive duplicate installs).
545
- else if (isLambderApiError(err) && ctx._otherInternal.isApiCall) {
546
- response = this.apiErrorResponse(err, resolver);
451
+ else if (isLambderApiRefusal(err) && ctx.api) {
452
+ response = this.apiErrorResponse(err, ctx);
547
453
  }
548
454
  else {
549
455
  throw err;
550
456
  }
551
457
  }
458
+ // What the call wrote goes on BEFORE the hooks run, so an
459
+ // afterRender hook can override or delete a header the handler
460
+ // wrote: replaying the operations afterwards put the handler's
461
+ // value straight back, and a hook could not win an argument it
462
+ // was the last to speak in.
463
+ const responseIntoHooks = response;
464
+ const headersAppliedIntoHooks = ctx.responseHeaders.size;
465
+ ctx.responseHeaders.applyTo(response);
552
466
  try {
553
467
  for (const hook of this.hookList["afterRender"]) {
554
468
  const hookResponse = await hook.hookFn(ctx, resolver, response);
@@ -561,54 +475,196 @@ export default class Lambder {
561
475
  if (err instanceof LambderResponse) {
562
476
  response = err;
563
477
  }
564
- else if (isLambderApiError(err) && ctx._otherInternal.isApiCall) {
565
- response = this.apiErrorResponse(err, resolver);
478
+ else if (isLambderApiRefusal(err) && ctx.api) {
479
+ response = this.apiErrorResponse(err, ctx);
566
480
  }
567
481
  else {
568
482
  throw err;
569
483
  }
570
484
  }
571
- // Apply setHeader, addHeader values.
572
- for (const header of ctx._otherInternal.setHeaderFnAccumulator) {
573
- response.setHeader(header.key, header.value);
574
- }
575
- for (const header of ctx._otherInternal.addHeaderFnAccumulator) {
576
- response.addHeader(header.key, header.value);
577
- }
578
- this.applyCors(ctx, response, false);
579
- return await finalizeResponse(ctx, response, this.finalizeOptions, ctx._otherInternal.eventFormat);
485
+ // Only what the hooks themselves wrote (res.setHeader inside a
486
+ // hook) is left to apply, which is what leaves their overrides
487
+ // standing. A hook that answered with a DIFFERENT response takes
488
+ // the whole set instead: headers belong to the call rather than to
489
+ // the response that first carried them, so the session cookie the
490
+ // call wrote has to travel across to it.
491
+ ctx.responseHeaders.applyTo(response, response === responseIntoHooks ? headersAppliedIntoHooks : 0);
492
+ this.applyCors(ctx, response, this.isCorsPreflight(ctx));
493
+ return await finalizeResponse(ctx, response, this.finalizeOptions, ctx.eventFormat);
580
494
  }
581
495
  catch (err) {
582
- const wrappedError = err instanceof Error ? err : new Error("Error: " + String(err));
496
+ // Describing the thrown value can itself throw: an object with a
497
+ // null prototype, a Proxy, or a throwing toString/Symbol.toPrimitive.
498
+ // Coercing it unguarded in the FIRST statement of the last-resort
499
+ // catch made the catch throw, so the error handler never ran, no
500
+ // envelope was produced, and the invocation rejected with a 502 no
501
+ // client could parse. coerceToError is the shared version of that
502
+ // care, the one every site in the framework now uses.
503
+ const wrappedError = coerceToError(err, "an unstringifiable thrown value");
583
504
  // ctx may be null (createContext failed): derive the format from the raw event.
584
- const eventFormat = ctx?._otherInternal.eventFormat ?? (isV2HttpEvent(event) ? "v2" : "v1");
505
+ const eventFormat = ctx?.eventFormat ?? (isV2HttpEvent(event) ? "v2" : "v1");
585
506
  try {
586
507
  if (this.globalErrorHandler) {
587
508
  const responseBuilder = this.getResponseBuilder(ctx ?? undefined);
588
- const errorResponse = await this.globalErrorHandler(wrappedError, ctx, responseBuilder, ctx?._otherInternal.logToApiResponseAccumulator);
509
+ const errorResponse = await this.globalErrorHandler(wrappedError, ctx, responseBuilder);
510
+ // The same rule the success path follows: headers belong
511
+ // to the call, not to the response that first carried
512
+ // them. A call that wrote a session cookie and then threw
513
+ // still owes the browser that cookie, and a cross-origin
514
+ // caller cannot read the error at all without the CORS
515
+ // headers.
516
+ ctx?.responseHeaders.applyTo(errorResponse);
517
+ if (ctx)
518
+ this.applyCors(ctx, errorResponse, false);
589
519
  return await finalizeResponse(ctx, errorResponse, this.finalizeOptions, eventFormat);
590
520
  }
591
521
  }
592
522
  catch (handlerErr) {
593
523
  if (handlerErr instanceof LambderResponse) {
524
+ ctx?.responseHeaders.applyTo(handlerErr);
525
+ if (ctx)
526
+ this.applyCors(ctx, handlerErr, false);
594
527
  try {
595
528
  return await finalizeResponse(ctx, handlerErr, this.finalizeOptions, eventFormat);
596
529
  }
597
530
  catch { /* fall through */ }
598
531
  }
599
532
  }
600
- // Last-resort 500. API calls get the JSON envelope so clients can
601
- // parse a structured failure; everything else keeps plain text.
602
- if (ctx?._otherInternal.isApiCall) {
603
- const apiBody = JSON.stringify({ apiVersion: this.apiVersion, payload: null, errorMessage: "Internal server error." });
604
- return eventFormat === "v2"
605
- ? { statusCode: 500, headers: { "Content-Type": "application/json; charset=utf-8" }, body: apiBody, isBase64Encoded: false }
606
- : { statusCode: 500, multiValueHeaders: { "Content-Type": ["application/json; charset=utf-8"] }, body: apiBody, isBase64Encoded: false };
533
+ // Last-resort 500. API calls get the core's crash envelope so
534
+ // clients can parse a structured failure; everything else keeps
535
+ // plain text. Emitted directly rather than finalized, because
536
+ // finalization may be what failed. The headers still go on: they
537
+ // belong to the call and not to the response that first carried
538
+ // them, so a call that wrote a session cookie and then threw still
539
+ // owes the browser that cookie, and a cross-origin caller cannot
540
+ // read this error at all without the CORS headers. Applying them
541
+ // is plain object work, none of the compression, base64 or size
542
+ // handling that finalization does.
543
+ const crashResponse = ctx?.api
544
+ ? responseFromAnswer(crashAnswer(this.apiVersion))
545
+ : new LambderResponse({ statusCode: 500, body: "Internal Server Error." });
546
+ ctx?.responseHeaders.applyTo(crashResponse);
547
+ if (ctx)
548
+ this.applyCors(ctx, crashResponse, false);
549
+ return emitResponse(eventFormat, crashResponse.statusCode, crashResponse.headers, typeof crashResponse.body === "string" ? crashResponse.body : "", false);
550
+ }
551
+ }
552
+ /**
553
+ * Fetch the session for a session route or short-circuit it with the
554
+ * sessionExpiredRouteHandler response (default 401). Session APIs never
555
+ * come through here: the pipeline answers them with the protocol's
556
+ * { sessionExpired: true } envelope itself.
557
+ */
558
+ async requireSession(ctx, resolver) {
559
+ const session = await this.getSessionController(ctx).fetchSessionIfExists();
560
+ if (!session) {
561
+ if (this.sessionExpiredRouteHandler) {
562
+ throw await this.sessionExpiredRouteHandler(ctx, resolver);
563
+ }
564
+ throw resolver.status(401, "Session required.");
565
+ }
566
+ }
567
+ /** Dispatch a non-HTTP Lambda event to the registered actions. */
568
+ async renderEvent(event, lambdaContext) {
569
+ await this.ensureInitialized();
570
+ for (const action of this.eventActionList) {
571
+ if (action.match(event)) {
572
+ return await action.actionFn(event, lambdaContext);
607
573
  }
608
- return eventFormat === "v2"
609
- ? { statusCode: 500, headers: {}, body: "Internal Server Error.", isBase64Encoded: false }
610
- : { statusCode: 500, multiValueHeaders: {}, body: "Internal Server Error.", isBase64Encoded: false };
611
574
  }
575
+ const summary = event && typeof event === "object"
576
+ ? ` (source: ${String(event.source ?? "?")}, detail-type: ${String(event["detail-type"] ?? "?")})`
577
+ : "";
578
+ throw new Error(`Lambder: no action matched non-HTTP event${summary}. Register one with addAction(); a trailing addAction(() => true, ...) acts as a fallback.`);
579
+ }
580
+ // =====================================================================
581
+ // The API path
582
+ // The steps only an API call takes, around the shared core.
583
+ // =====================================================================
584
+ /** Registration-time checks shared by addApi/addSessionApi. */
585
+ assertApiRegistration(name, mode, options) {
586
+ if (this.registeredApiNames.has(name)) {
587
+ throw new Error(`Lambder: duplicate API name "${name}". Dispatch is first-match, so the second registration would be silently dead code.`);
588
+ }
589
+ // Everything that can refuse this registration runs before the name is
590
+ // claimed. Claiming it first meant a caught registration error burned
591
+ // the name, and the retry reported a duplicate instead of the problem
592
+ // the app was fixing; the session check was still on the far side of
593
+ // that line, one method down in addSessionApi.
594
+ if (mode === "session" && !this.pipeline.hasSessions) {
595
+ throw new Error(`Lambder: session API "${name}" needs the session option at creation.`);
596
+ }
597
+ const guardsRequired = mode === "session" ? this.requireSessionApiGuards : this.requirePublicApiGuards;
598
+ if (guardsRequired && options.guards === undefined) {
599
+ const optOut = mode === "session"
600
+ ? "the named no-op guard that marks the session itself as the whole authorization"
601
+ : "the named no-op guard that records why anyone may call it";
602
+ throw new Error(`Lambder: ${mode} API "${name}" declares no guards, and require${mode === "session" ? "Session" : "Public"}ApiGuards is on. ` +
603
+ `Declare the guard that authorizes it, or ${optOut}.`);
604
+ }
605
+ this.pipeline.assertRegistration({ name, mode, guards: options.guards, rateLimit: options.rateLimit, idempotency: options.idempotency });
606
+ this.registeredApiNames.add(name);
607
+ }
608
+ /**
609
+ * The answer for a rejected input: the app's
610
+ * setApiInputValidationErrorHandler when set, otherwise the standard 422
611
+ * body. The API's own schema and every preflight slice (guard inputs,
612
+ * rate-limit keys) answer through here, so one failure has one shape.
613
+ */
614
+ async inputValidationRefusal(ctx, zodError) {
615
+ if (this.apiInputValidationErrorHandler) {
616
+ return answerFromResponse(await this.apiInputValidationErrorHandler(ctx, this.getResolver(ctx), zodError));
617
+ }
618
+ // null asks the pipeline for the standard 422, so that answer is
619
+ // written once, in the core, rather than here as well.
620
+ return null;
621
+ }
622
+ /**
623
+ * One API call through the core: the pipeline runs the protocol steps and
624
+ * calls back for the handler, whose LambderResponse (returned, or thrown
625
+ * via res.die.*) becomes the answer the pipeline stores and hands back.
626
+ * The context is the pipeline's context, so a session it fetched is on
627
+ * ctx.session and the validated payload is on ctx.apiPayload when the
628
+ * handler runs.
629
+ */
630
+ async runApi(ctx, resolver, definition, handler) {
631
+ const request = ctx.api;
632
+ if (!request)
633
+ throw new Error(`Lambder: API "${definition.name}" was matched by a request that is not an API call.`);
634
+ // What the handler produced, in both forms: the answer went to the
635
+ // pipeline, and the response is kept so it can carry on unchanged.
636
+ const handled = { output: null };
637
+ const { answer } = await this.pipeline.run(request, ctx, definition, async () => {
638
+ ctx.apiPayload = request.payload;
639
+ let response;
640
+ try {
641
+ response = await handler(ctx, resolver);
642
+ }
643
+ catch (err) {
644
+ // A thrown LambderResponse IS the response (res.die.*): an
645
+ // answer like a returned one, stored and replayed alike.
646
+ if (err instanceof LambderResponse)
647
+ response = err;
648
+ else
649
+ throw err;
650
+ }
651
+ handled.output = { response, answer: answerFromResponse(response) };
652
+ return handled.output.answer;
653
+ });
654
+ // The handler's own response carries on when the pipeline answered
655
+ // with it, rather than a rebuild of its answer. An answer holds a
656
+ // Buffer body base64-encoded, because that is the plain shape the
657
+ // idempotency store persists, and rebuilding a response from that
658
+ // would hand finalization a base64 string it must pass through
659
+ // uncompressed. Identity is what settles it: the pipeline may have
660
+ // answered with a stored replay or a refusal instead.
661
+ if (handled.output?.answer === answer)
662
+ return handled.output.response;
663
+ return responseFromAnswer(answer);
664
+ }
665
+ /** A thrown LambderApiRefusal (from a hook, say) as the structured API envelope: the core's one mapping. */
666
+ apiErrorResponse(err, ctx) {
667
+ return responseFromAnswer(refusalAnswer(err, this.apiVersion, ctx.logList));
612
668
  }
613
669
  }
614
670
  /**
@@ -623,7 +679,7 @@ export default class Lambder {
623
679
  * // app.ts (imports no api modules, so modules can import the type back)
624
680
  * export const lambderApp = initLambder<SessionData>().create({
625
681
  * apiPath: "/api",
626
- * session: { tableName: "app-session", tableRegion: "us-east-1", sessionSalt: "..." },
682
+ * session: { store: new LambderDdbSessionStore({ tableName: "app-session", region: "us-east-1" }), sessionSalt: "..." },
627
683
  * rateLimits: { limiter, policies },
628
684
  * guards,
629
685
  * idempotency: { store },
@@ -651,26 +707,3 @@ export const initLambder = () => ({
651
707
  return new Lambder(options);
652
708
  },
653
709
  });
654
- /** Rebuild the query string from the API Gateway event for redirects. */
655
- const buildQueryString = (ctx) => {
656
- if (isV2HttpEvent(ctx.event)) {
657
- return ctx.event.rawQueryString ? `?${ctx.event.rawQueryString}` : "";
658
- }
659
- const multi = ctx.event.multiValueQueryStringParameters;
660
- const single = ctx.event.queryStringParameters;
661
- const params = new URLSearchParams();
662
- if (multi) {
663
- for (const [key, values] of Object.entries(multi)) {
664
- for (const value of values ?? [])
665
- params.append(key, value);
666
- }
667
- }
668
- else if (single) {
669
- for (const [key, value] of Object.entries(single)) {
670
- if (value !== undefined)
671
- params.append(key, value);
672
- }
673
- }
674
- const queryString = params.toString();
675
- return queryString ? `?${queryString}` : "";
676
- };