lambder 6.0.2 → 7.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/CHANGELOG.md +2316 -0
  2. package/README.md +60 -33
  3. package/dist/api/LambderApiAnswer.d.ts +40 -0
  4. package/dist/api/LambderApiAnswer.js +19 -0
  5. package/dist/api/LambderApiCallContext.d.ts +38 -0
  6. package/dist/api/LambderApiCallContext.js +13 -0
  7. package/dist/api/LambderApiDefinition.d.ts +18 -0
  8. package/dist/api/LambderApiDefinition.js +1 -0
  9. package/dist/api/LambderApiEnvelope.d.ts +67 -0
  10. package/dist/api/LambderApiEnvelope.js +180 -0
  11. package/dist/api/LambderApiGuards.d.ts +302 -0
  12. package/dist/api/LambderApiGuards.js +134 -0
  13. package/dist/api/LambderApiIdempotency.d.ts +122 -0
  14. package/dist/api/LambderApiIdempotency.js +330 -0
  15. package/dist/api/LambderApiPipeline.d.ts +134 -0
  16. package/dist/api/LambderApiPipeline.js +221 -0
  17. package/dist/api/LambderApiPolicyEngine.d.ts +36 -0
  18. package/dist/api/LambderApiPolicyEngine.js +77 -0
  19. package/dist/api/LambderApiRateLimits.d.ts +206 -0
  20. package/dist/api/LambderApiRateLimits.js +239 -0
  21. package/dist/api/LambderApiRequest.d.ts +101 -0
  22. package/dist/api/LambderApiRequest.js +129 -0
  23. package/dist/api/LambderApiValidationRefusal.d.ts +32 -0
  24. package/dist/api/LambderApiValidationRefusal.js +40 -0
  25. package/dist/client/LambderCaller.d.ts +62 -55
  26. package/dist/client/LambderCaller.js +147 -90
  27. package/dist/client/lambderFetchTransport.d.ts +9 -0
  28. package/dist/client/lambderFetchTransport.js +71 -0
  29. package/dist/client.d.ts +20 -10
  30. package/dist/client.js +11 -5
  31. package/dist/core/Lambder.d.ts +117 -253
  32. package/dist/core/Lambder.js +374 -341
  33. package/dist/core/LambderContext.d.ts +54 -44
  34. package/dist/core/LambderContext.js +41 -110
  35. package/dist/core/LambderCreateOptions.d.ts +285 -0
  36. package/dist/core/LambderCreateOptions.js +44 -0
  37. package/dist/core/LambderFiles.d.ts +1 -45
  38. package/dist/core/LambderFiles.js +18 -38
  39. package/dist/core/LambderIndexHtml.d.ts +37 -0
  40. package/dist/core/LambderIndexHtml.js +87 -0
  41. package/dist/core/LambderPolicyBuilders.d.ts +17 -0
  42. package/dist/core/LambderPolicyBuilders.js +16 -0
  43. package/dist/core/LambderPublicFiles.d.ts +5 -2
  44. package/dist/core/LambderPublicFiles.js +7 -2
  45. package/dist/core/LambderResolver.d.ts +8 -6
  46. package/dist/core/LambderResponse.d.ts +29 -11
  47. package/dist/core/LambderResponse.js +96 -49
  48. package/dist/core/LambderResponseBuilder.d.ts +18 -14
  49. package/dist/core/LambderResponseBuilder.js +19 -25
  50. package/dist/core/LambderRouting.d.ts +18 -7
  51. package/dist/core/LambderRouting.js +17 -7
  52. package/dist/core/LambderTemplatingEngine.d.ts +0 -62
  53. package/dist/core/LambderTemplatingEngine.js +7 -3
  54. package/dist/index.d.ts +85 -32
  55. package/dist/index.js +44 -16
  56. package/dist/invoke/LambderInvokeCaller.d.ts +46 -139
  57. package/dist/invoke/LambderInvokeCaller.js +140 -335
  58. package/dist/invoke/LambderInvokeOutcome.d.ts +165 -0
  59. package/dist/invoke/LambderInvokeOutcome.js +129 -0
  60. package/dist/invoke/LambderLambdaEvent.d.ts +81 -0
  61. package/dist/invoke/LambderLambdaEvent.js +187 -0
  62. package/dist/invoke/lambderHandlerTransport.d.ts +36 -0
  63. package/dist/invoke/lambderHandlerTransport.js +89 -0
  64. package/dist/mock/LambderMockApp.d.ts +352 -0
  65. package/dist/mock/LambderMockApp.js +815 -0
  66. package/dist/mock/LambderMockBrowserCookies.d.ts +55 -0
  67. package/dist/mock/LambderMockBrowserCookies.js +76 -0
  68. package/dist/mock/LambderMockCallRecorder.d.ts +85 -0
  69. package/dist/mock/LambderMockCallRecorder.js +183 -0
  70. package/dist/mock/LambderMockCreateOptions.d.ts +161 -0
  71. package/dist/mock/LambderMockCreateOptions.js +9 -0
  72. package/dist/mock/LambderMockEntryRegistry.d.ts +52 -0
  73. package/dist/mock/LambderMockEntryRegistry.js +126 -0
  74. package/dist/mock/LambderMockFailureInjector.d.ts +60 -0
  75. package/dist/mock/LambderMockFailureInjector.js +138 -0
  76. package/dist/mock/LambderMockTypes.d.ts +421 -0
  77. package/dist/mock/LambderMockTypes.js +8 -0
  78. package/dist/mock/lambderMockConsoleLogger.d.ts +16 -0
  79. package/dist/mock/lambderMockConsoleLogger.js +35 -0
  80. package/dist/mock/lambderMockInvokeTransport.d.ts +50 -0
  81. package/dist/mock/lambderMockInvokeTransport.js +52 -0
  82. package/dist/mock/lambderMockMswHandler.d.ts +99 -0
  83. package/dist/mock/lambderMockMswHandler.js +126 -0
  84. package/dist/mock.d.ts +34 -0
  85. package/dist/mock.js +27 -0
  86. package/dist/session/LambderSessionController.d.ts +199 -30
  87. package/dist/session/LambderSessionController.js +396 -82
  88. package/dist/session/LambderSessionCrypto.d.ts +66 -0
  89. package/dist/session/LambderSessionCrypto.js +101 -0
  90. package/dist/session/LambderSessionManager.d.ts +118 -80
  91. package/dist/session/LambderSessionManager.js +212 -184
  92. package/dist/shared/LambderI18n.d.ts +6 -6
  93. package/dist/shared/LambderI18n.js +1 -1
  94. package/dist/shared/contracts/LambderFileSource.d.ts +33 -0
  95. package/dist/shared/contracts/LambderFileSource.js +19 -0
  96. package/dist/shared/contracts/LambderIdempotencyStore.d.ts +66 -0
  97. package/dist/shared/contracts/LambderIdempotencyStore.js +12 -0
  98. package/dist/shared/contracts/LambderRateLimiter.d.ts +71 -0
  99. package/dist/shared/contracts/LambderRateLimiter.js +24 -0
  100. package/dist/shared/contracts/LambderSessionStore.d.ts +72 -0
  101. package/dist/shared/contracts/LambderSessionStore.js +13 -0
  102. package/dist/shared/transport/LambderApiTransport.d.ts +139 -0
  103. package/dist/shared/transport/LambderApiTransport.js +65 -0
  104. package/dist/shared/transport/LambderCookieJar.d.ts +121 -0
  105. package/dist/shared/transport/LambderCookieJar.js +246 -0
  106. package/dist/shared/transport/lambderCookieJarTransport.d.ts +30 -0
  107. package/dist/shared/transport/lambderCookieJarTransport.js +60 -0
  108. package/dist/shared/util/LambderBase64.d.ts +10 -0
  109. package/dist/shared/util/LambderBase64.js +27 -0
  110. package/dist/shared/util/LambderCallAbort.d.ts +62 -0
  111. package/dist/shared/util/LambderCallAbort.js +80 -0
  112. package/dist/shared/util/LambderClientIp.d.ts +32 -0
  113. package/dist/shared/util/LambderClientIp.js +56 -0
  114. package/dist/shared/util/LambderExpiringMap.d.ts +119 -0
  115. package/dist/shared/util/LambderExpiringMap.js +217 -0
  116. package/dist/shared/util/LambderKeyFields.d.ts +32 -0
  117. package/dist/shared/util/LambderKeyFields.js +34 -0
  118. package/dist/shared/util/LambderNodeModules.d.ts +9 -0
  119. package/dist/shared/util/LambderNodeModules.js +39 -0
  120. package/dist/shared/util/LambderOptionChecks.d.ts +17 -0
  121. package/dist/shared/util/LambderOptionChecks.js +33 -0
  122. package/dist/shared/util/LambderResponseBrand.d.ts +20 -0
  123. package/dist/shared/util/LambderResponseBrand.js +18 -0
  124. package/dist/shared/util/LambderTextDigest.d.ts +17 -0
  125. package/dist/shared/util/LambderTextDigest.js +34 -0
  126. package/dist/shared/util/LambderTypeUtilities.d.ts +33 -0
  127. package/dist/shared/util/LambderTypeUtilities.js +8 -0
  128. package/dist/shared/wire/LambderAnswerHeaders.d.ts +60 -0
  129. package/dist/shared/wire/LambderAnswerHeaders.js +94 -0
  130. package/dist/shared/wire/LambderApiContract.d.ts +129 -0
  131. package/dist/shared/wire/LambderApiOptionValues.d.ts +39 -0
  132. package/dist/shared/wire/LambderApiOptionValues.js +11 -0
  133. package/dist/shared/wire/LambderApiOutcome.d.ts +128 -0
  134. package/dist/shared/{LambderApiOutcome.js → wire/LambderApiOutcome.js} +16 -9
  135. package/dist/shared/{LambderApiError.d.ts → wire/LambderApiRefusal.d.ts} +48 -26
  136. package/dist/shared/{LambderApiError.js → wire/LambderApiRefusal.js} +13 -11
  137. package/dist/shared/wire/LambderCallOptions.d.ts +171 -0
  138. package/dist/shared/wire/LambderCallOptions.js +17 -0
  139. package/dist/shared/{LambderCompressionCodec.d.ts → wire/LambderCompressionCodec.d.ts} +10 -6
  140. package/dist/shared/{LambderCompressionCodec.js → wire/LambderCompressionCodec.js} +67 -23
  141. package/dist/shared/{LambderCompressionOption.d.ts → wire/LambderCompressionOption.d.ts} +1 -1
  142. package/dist/shared/{LambderCompressionOption.js → wire/LambderCompressionOption.js} +3 -4
  143. package/dist/shared/{LambderCrashDetail.d.ts → wire/LambderCrashDetail.d.ts} +10 -0
  144. package/dist/shared/{LambderCrashDetail.js → wire/LambderCrashDetail.js} +30 -0
  145. package/dist/shared/wire/LambderHttpStatus.d.ts +12 -0
  146. package/dist/shared/wire/LambderHttpStatus.js +1 -0
  147. package/dist/shared/{LambderRequestPayload.d.ts → wire/LambderRequestPayload.d.ts} +25 -17
  148. package/dist/shared/{LambderRequestPayload.js → wire/LambderRequestPayload.js} +29 -52
  149. package/dist/shared/wire/LambderSessionCookieNames.d.ts +9 -0
  150. package/dist/shared/wire/LambderSessionCookieNames.js +9 -0
  151. package/dist/stores/LambderDdbCache.d.ts +12 -9
  152. package/dist/stores/LambderDdbCache.js +56 -47
  153. package/dist/stores/{LambderDdbIdempotency.d.ts → LambderDdbIdempotencyStore.d.ts} +41 -31
  154. package/dist/stores/LambderDdbIdempotencyStore.js +319 -0
  155. package/dist/stores/LambderDdbRateLimiter.d.ts +30 -49
  156. package/dist/stores/LambderDdbRateLimiter.js +47 -45
  157. package/dist/stores/LambderDdbSdk.d.ts +83 -6
  158. package/dist/stores/LambderDdbSdk.js +83 -2
  159. package/dist/stores/LambderDdbSessionStore.d.ts +65 -0
  160. package/dist/stores/LambderDdbSessionStore.js +161 -0
  161. package/dist/stores/LambderHttpFileSource.d.ts +1 -1
  162. package/dist/stores/LambderHttpFileSource.js +10 -1
  163. package/dist/stores/LambderLocalFileSource.d.ts +15 -0
  164. package/dist/stores/LambderLocalFileSource.js +28 -0
  165. package/dist/stores/LambderMemoryIdempotencyStore.d.ts +63 -0
  166. package/dist/stores/LambderMemoryIdempotencyStore.js +113 -0
  167. package/dist/stores/LambderMemoryRateLimiter.d.ts +34 -0
  168. package/dist/stores/LambderMemoryRateLimiter.js +64 -0
  169. package/dist/stores/LambderMemorySessionStore.d.ts +48 -0
  170. package/dist/stores/LambderMemorySessionStore.js +74 -0
  171. package/dist/stores/LambderS3FileSource.d.ts +1 -1
  172. package/dist/stores/LambderS3FileSource.js +1 -1
  173. package/package.json +21 -19
  174. package/dist/client/LambderMSW.d.ts +0 -69
  175. package/dist/client/LambderMSW.js +0 -121
  176. package/dist/policies/LambderApiGuards.d.ts +0 -256
  177. package/dist/policies/LambderApiGuards.js +0 -94
  178. package/dist/policies/LambderApiIdempotency.d.ts +0 -58
  179. package/dist/policies/LambderApiIdempotency.js +0 -219
  180. package/dist/policies/LambderApiPolicies.d.ts +0 -42
  181. package/dist/policies/LambderApiPolicies.js +0 -52
  182. package/dist/policies/LambderApiRateLimits.d.ts +0 -132
  183. package/dist/policies/LambderApiRateLimits.js +0 -119
  184. package/dist/shared/LambderApiContract.d.ts +0 -57
  185. package/dist/shared/LambderApiOutcome.d.ts +0 -69
  186. package/dist/shared/LambderCallOptions.d.ts +0 -71
  187. package/dist/shared/LambderCallOptions.js +0 -16
  188. package/dist/shared/node-polyfills.d.ts +0 -4
  189. package/dist/shared/node-polyfills.js +0 -58
  190. package/dist/stores/LambderDdbIdempotency.js +0 -229
  191. package/dist/testing.d.ts +0 -9
  192. package/dist/testing.js +0 -8
  193. /package/dist/shared/{LambderApiContract.js → wire/LambderApiContract.js} +0 -0
  194. /package/dist/{core → shared/wire}/LambderCookie.d.ts +0 -0
  195. /package/dist/{core → shared/wire}/LambderCookie.js +0 -0
@@ -20,234 +20,22 @@
20
20
  * LambderInvokeCaller.localTransport runs a callee's handler in-process for
21
21
  * tests.
22
22
  */
23
- import { resolveApiOutcome } from "../shared/LambderApiOutcome.js";
24
- import { mergeGuardInputs, } from "../shared/LambderCallOptions.js";
25
- import { errorFromCrashDetail } from "../shared/LambderCrashDetail.js";
26
- import { resolveCompressionOption } from "../shared/LambderCompressionOption.js";
27
- import { compressText, restoreBytes } from "../shared/LambderCompressionCodec.js";
28
- import { COMPRESSED_PAYLOAD_BR_FIELD, DEFAULT_MAX_RESTORED_PAYLOAD_BYTES, compressPayloadWith, } from "../shared/LambderRequestPayload.js";
29
- /** Marks a synthesized request as an invoke, for guards and hooks that want to tell. Not an authorization. */
30
- export const LAMBDER_INVOKE_HEADER = "x-lambder-invoke";
31
- /** The invoking function's name, when the caller runs in Lambda; for the callee's logs. */
32
- export const LAMBDER_INVOKED_BY_HEADER = "x-lambder-invoked-by";
33
- /** The value of the marker header; a future incompatible event shape would bump it. */
34
- export const LAMBDER_INVOKE_PROTOCOL = "1";
23
+ import { classifyDeliveryFailure, describeFailure, errorFromFunctionError, LambderInvokeError, parseFunctionError, } from "./LambderInvokeOutcome.js";
24
+ import { DEFAULT_SESSION_TOKEN_COOKIE_KEY } from "../shared/wire/LambderSessionCookieNames.js";
25
+ import { resolveApiOutcome } from "../shared/wire/LambderApiOutcome.js";
26
+ import { mergeGuardInputs, } from "../shared/wire/LambderCallOptions.js";
27
+ import { createCallAbort, stopWaitingWhenAborted } from "../shared/util/LambderCallAbort.js";
28
+ import { coerceToError, errorFromCrashDetail } from "../shared/wire/LambderCrashDetail.js";
29
+ import { assertPositiveInteger } from "../shared/util/LambderOptionChecks.js";
30
+ import { resolveCompressionOption } from "../shared/wire/LambderCompressionOption.js";
31
+ import { DEFAULT_INVOKE_REQUEST_COMPRESSION_SETTINGS, DEFAULT_MAX_RESTORED_PAYLOAD_BYTES, compressPayloadBrotli, resolveRequestCompressionMinBytes, } from "../shared/wire/LambderRequestPayload.js";
32
+ import { buildEnvelopeJson, decodeLambdaHttpResult, localLambdaContext, sessionCookies, synthesizeLambdaHttpEvent, } from "./LambderLambdaEvent.js";
35
33
  /**
36
34
  * Lambda caps a synchronous invoke's request and its response at about 6MB;
37
35
  * the same guard threshold finalizeResponse applies to an answer, applied
38
36
  * here to the event before it is sent.
39
37
  */
40
38
  export const LAMBDER_INVOKE_MAX_EVENT_BYTES = 5_500_000;
41
- /** Request Brotli when `requestCompression: true`: the HTTP request threshold, at the quality every other Lambder site uses. */
42
- export const DEFAULT_INVOKE_REQUEST_COMPRESSION_SETTINGS = { minBytes: 4096, quality: 5 };
43
- const DEFAULT_SESSION_TOKEN_COOKIE_KEY = "LMDRSESSIONTKID";
44
- /**
45
- * What api() throws. Its message names the function, the API and the reason,
46
- * so an error reporter that fingerprints on the message groups one broken
47
- * API into one row; its cause is the callee's own error rebuilt from the
48
- * crash detail (or Lambda's FunctionError, or the SDK's rejection), so a
49
- * reporter that walks causes stores the callee's stack.
50
- */
51
- export class LambderInvokeError extends Error {
52
- /** Brand for detection across duplicate lambder installs, like LambderApiError. */
53
- isLambderInvokeError = true;
54
- reason;
55
- apiName;
56
- functionName;
57
- status;
58
- errorMessage;
59
- crash;
60
- functionError;
61
- logList;
62
- zodError;
63
- retryAfterSeconds;
64
- bytes;
65
- /** The full failure outcome; it carries this error and this error carries it. */
66
- outcome;
67
- constructor(init) {
68
- super(init.message, init.cause !== undefined ? { cause: init.cause } : undefined);
69
- this.name = "LambderInvokeError";
70
- this.reason = init.reason;
71
- this.apiName = init.apiName;
72
- this.functionName = init.functionName;
73
- this.status = init.status;
74
- this.errorMessage = init.errorMessage;
75
- this.crash = init.crash;
76
- this.functionError = init.functionError;
77
- this.logList = init.logList;
78
- this.zodError = init.zodError;
79
- this.retryAfterSeconds = init.retryAfterSeconds;
80
- this.bytes = init.bytes;
81
- }
82
- }
83
- /** Brand-based type guard (see LambderInvokeError.isLambderInvokeError). */
84
- export const isLambderInvokeError = (err) => err instanceof Error && err.isLambderInvokeError === true;
85
- const randomRequestId = () => {
86
- const webCrypto = globalThis.crypto;
87
- if (webCrypto?.randomUUID)
88
- return webCrypto.randomUUID();
89
- return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 10)}`;
90
- };
91
- /** The payload-format-2.0 event API Gateway would deliver for this request. */
92
- const synthesizeHttpEvent = (request) => {
93
- const headers = {
94
- host: request.host,
95
- "accept-encoding": "br, gzip",
96
- [LAMBDER_INVOKE_HEADER]: LAMBDER_INVOKE_PROTOCOL,
97
- };
98
- const invokedBy = typeof process !== "undefined" ? process.env?.AWS_LAMBDA_FUNCTION_NAME : undefined;
99
- if (invokedBy)
100
- headers[LAMBDER_INVOKED_BY_HEADER] = invokedBy;
101
- if (request.clientIp)
102
- headers["x-forwarded-for"] = request.clientIp;
103
- for (const [key, value] of Object.entries(request.headers ?? {}))
104
- headers[key.toLowerCase()] = value;
105
- const isBinary = Buffer.isBuffer(request.body);
106
- if (request.body !== undefined && !headers["content-type"]) {
107
- headers["content-type"] = isBinary ? "application/octet-stream" : "application/json";
108
- }
109
- const now = Date.now();
110
- return {
111
- version: "2.0",
112
- routeKey: "$default",
113
- rawPath: request.path,
114
- rawQueryString: new URLSearchParams(request.query ?? {}).toString(),
115
- headers,
116
- ...(request.cookies?.length ? { cookies: request.cookies } : {}),
117
- requestContext: {
118
- accountId: "",
119
- apiId: "lambder-invoke",
120
- domainName: request.host,
121
- domainPrefix: "",
122
- http: {
123
- method: request.method,
124
- path: request.path,
125
- protocol: "HTTP/1.1",
126
- sourceIp: request.clientIp ?? "",
127
- userAgent: "lambder-invoke",
128
- },
129
- requestId: randomRequestId(),
130
- routeKey: "$default",
131
- stage: "$default",
132
- time: new Date(now).toISOString(),
133
- timeEpoch: now,
134
- },
135
- ...(request.body !== undefined
136
- ? { body: isBinary ? request.body.toString("base64") : request.body }
137
- : {}),
138
- isBase64Encoded: isBinary,
139
- };
140
- };
141
- /**
142
- * The body envelope LambderCaller sends, minus the fields only a browser has
143
- * a value for, as JSON. A plain payload arrives already serialized (the
144
- * compression decision needed its JSON) and is spliced in rather than
145
- * parsed and stringified a second time; a compressed one rides as its two
146
- * fields.
147
- */
148
- const buildEnvelopeJson = (fields) => {
149
- const withoutPayload = JSON.stringify({
150
- apiName: fields.apiName,
151
- version: fields.version,
152
- token: fields.csrf ?? "",
153
- siteHost: fields.siteHost,
154
- ...(fields.compressed ?? {}),
155
- ...(fields.guardInputs !== undefined ? { guardInputs: fields.guardInputs } : {}),
156
- ...(fields.idempotencyKey !== undefined ? { idempotencyKey: fields.idempotencyKey } : {}),
157
- });
158
- if (fields.payloadJson === undefined)
159
- return withoutPayload;
160
- return `${withoutPayload.slice(0, -1)},"payload":${fields.payloadJson}}`;
161
- };
162
- const sessionCookies = (session, tokenCookieKey) => session ? [`${tokenCookieKey}=${session.token}`] : undefined;
163
- /**
164
- * Brotli one payload's JSON for sending, or null when the plain JSON should
165
- * go instead: the browser's compressPayloadGzip with Brotli, because both
166
- * ends are Node. The threshold and the only-when-smaller rule are
167
- * compressPayloadWith's, shared with the gzip side.
168
- */
169
- export const compressPayloadBrotli = (json, minBytes, quality) => compressPayloadWith(json, minBytes, COMPRESSED_PAYLOAD_BR_FIELD, (bytes) => compressText(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength), "br", quality));
170
- /** A timeout controller chained to an external signal, so either source aborts the invoke. */
171
- const abortAfter = (timeoutMs, external) => {
172
- let timedOut = false;
173
- let signal = external;
174
- let timeoutId;
175
- // The forwarding listener is detached in clear(), not left to `once`: an
176
- // external signal usually outlives the call (a request-scoped one passed
177
- // to several invokes, an app-lifetime one), so a listener per call would
178
- // accumulate on it for as long as it lives.
179
- let detach;
180
- if (timeoutMs !== undefined) {
181
- const controller = new AbortController();
182
- if (external) {
183
- if (external.aborted) {
184
- controller.abort(external.reason);
185
- }
186
- else {
187
- const forward = () => controller.abort(external.reason);
188
- external.addEventListener("abort", forward, { once: true });
189
- detach = () => external.removeEventListener("abort", forward);
190
- }
191
- }
192
- timeoutId = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs);
193
- signal = controller.signal;
194
- }
195
- return {
196
- signal,
197
- timedOut: () => timedOut,
198
- clear: () => { if (timeoutId !== undefined)
199
- clearTimeout(timeoutId); detach?.(); },
200
- };
201
- };
202
- const errorFromFunctionError = (functionError) => {
203
- const error = new Error(functionError.errorMessage ?? "the function failed");
204
- error.name = functionError.errorType ?? "FunctionError";
205
- if (functionError.trace?.length)
206
- error.stack = functionError.trace.join("\n");
207
- return error;
208
- };
209
- const parseFunctionError = (result) => {
210
- if (result && typeof result === "object") {
211
- const { errorType, errorMessage, trace } = result;
212
- return {
213
- ...(typeof errorType === "string" ? { errorType } : {}),
214
- ...(typeof errorMessage === "string" ? { errorMessage } : {}),
215
- ...(Array.isArray(trace) ? { trace: trace.map(String) } : {}),
216
- };
217
- }
218
- return { errorMessage: typeof result === "string" ? result : undefined };
219
- };
220
- /** The one-line detail a failure's message ends with. */
221
- const describeFailure = (init) => {
222
- if (init.crash)
223
- return init.crash.message;
224
- if (init.functionError)
225
- return `${init.functionError.errorType ?? "FunctionError"}: ${init.functionError.errorMessage ?? "the function failed"}`;
226
- if (init.errorMessage !== undefined) {
227
- const content = init.errorMessage?.content;
228
- if (typeof content === "string")
229
- return content;
230
- if (typeof init.errorMessage === "string")
231
- return init.errorMessage;
232
- try {
233
- return JSON.stringify(init.errorMessage);
234
- }
235
- catch {
236
- return String(init.errorMessage);
237
- }
238
- }
239
- if (init.reason === 'validation')
240
- return "the callee rejected the input";
241
- if (init.reason === 'versionExpired')
242
- return "the callee answered versionExpired";
243
- if (init.reason === 'sessionExpired')
244
- return "the callee answered sessionExpired";
245
- if (init.reason === 'notAuthorized')
246
- return "the callee answered notAuthorized";
247
- if (init.cause instanceof Error)
248
- return init.cause.message;
249
- return init.status !== undefined ? `HTTP ${init.status}` : "no answer";
250
- };
251
39
  /**
252
40
  * @typeParam TContract - The callee's API contract (`typeof lambder.ApiContract`, imported type-only), for typed names, payloads, results and guard inputs.
253
41
  * @typeParam TProvidedGuards - Guard names guardInputsProvider covers; those APIs' options argument becomes optional.
@@ -280,10 +68,7 @@ export default class LambderInvokeCaller {
280
68
  this.host = host ?? functionName;
281
69
  // `?? false`: like the browser caller, off unless asked for.
282
70
  this.requestCompression = resolveCompressionOption(requestCompression ?? false, DEFAULT_INVOKE_REQUEST_COMPRESSION_SETTINGS);
283
- this.maxResponsePayloadBytes = maxResponsePayloadBytes ?? DEFAULT_MAX_RESTORED_PAYLOAD_BYTES;
284
- if (!Number.isSafeInteger(this.maxResponsePayloadBytes) || this.maxResponsePayloadBytes <= 0) {
285
- throw new Error("LambderInvokeCaller: maxResponsePayloadBytes must be a positive integer");
286
- }
71
+ this.maxResponsePayloadBytes = assertPositiveInteger(maxResponsePayloadBytes ?? DEFAULT_MAX_RESTORED_PAYLOAD_BYTES, "LambderInvokeCaller maxResponsePayloadBytes");
287
72
  this.timeoutMs = timeoutMs;
288
73
  this.onLogList = onLogList;
289
74
  this.onFailure = onFailure;
@@ -298,7 +83,7 @@ export default class LambderInvokeCaller {
298
83
  static createEvent(init) {
299
84
  const host = init.host ?? "lambder-invoke";
300
85
  const tokenCookieKey = init.sessionTokenCookieKey ?? DEFAULT_SESSION_TOKEN_COOKIE_KEY;
301
- return synthesizeHttpEvent({
86
+ return synthesizeLambdaHttpEvent({
302
87
  method: "POST",
303
88
  path: init.apiPath ?? "/api",
304
89
  host,
@@ -314,35 +99,34 @@ export default class LambderInvokeCaller {
314
99
  guardInputs: init.guardInputs,
315
100
  idempotencyKey: init.idempotencyKey,
316
101
  }),
317
- });
102
+ }, { invoke: true });
318
103
  }
319
104
  /**
320
105
  * A transport that runs a callee's handler in this process, the way
321
106
  * Lambda would: a thrown error becomes a FunctionError payload. For
322
107
  * tests that want the real handlers behind the real envelope.
108
+ *
109
+ * It honours the signal the way lambderHandlerTransport does, by ending
110
+ * the wait: a function call in this process cannot be cancelled, so the
111
+ * handler runs to completion regardless and what a timeout buys is the
112
+ * caller's answer. Ignoring it made timeoutMs a no-op here.
323
113
  */
324
114
  static localTransport(handler, context = {}) {
325
- return async (event, { functionName }) => {
326
- const lambdaContext = {
327
- callbackWaitsForEmptyEventLoop: false,
328
- functionName,
329
- functionVersion: "$LATEST",
330
- invokedFunctionArn: `arn:aws:lambda:local:000000000000:function:${functionName}`,
331
- memoryLimitInMB: "128",
332
- awsRequestId: randomRequestId(),
333
- logGroupName: `/aws/lambda/${functionName}`,
334
- logStreamName: "local",
335
- getRemainingTimeInMillis: () => 30_000,
336
- done: () => { },
337
- fail: () => { },
338
- succeed: () => { },
339
- ...context,
340
- };
115
+ return async (event, { functionName, signal }) => {
116
+ signal?.throwIfAborted();
341
117
  try {
342
- return { functionError: null, result: await handler(event, lambdaContext) };
118
+ return {
119
+ functionError: null,
120
+ result: await stopWaitingWhenAborted(handler(event, localLambdaContext(functionName, context)), signal),
121
+ };
343
122
  }
344
123
  catch (err) {
345
- const error = err instanceof Error ? err : new Error(String(err));
124
+ // An abort is the caller giving up, not the callee failing:
125
+ // reporting it as a FunctionError would turn a timeout into a
126
+ // crash outcome with an invented error payload.
127
+ if (signal?.aborted && err === signal.reason)
128
+ throw err;
129
+ const error = coerceToError(err, "the handler failed");
346
130
  return {
347
131
  functionError: "Unhandled",
348
132
  result: { errorType: error.name, errorMessage: error.message, trace: (error.stack ?? "").split("\n") },
@@ -360,8 +144,11 @@ export default class LambderInvokeCaller {
360
144
  }
361
145
  async invokeThroughSdk(eventJson, signal) {
362
146
  const { LambdaClient, InvokeCommand } = await this.loadSdk();
147
+ // One call is one delivery attempt, which is what LambderApiTransport
148
+ // promises: the SDK's own default of 3 would re-invoke a callee that
149
+ // already ran when only the response was lost.
363
150
  if (!this.client)
364
- this.client = new LambdaClient(this.clientConfig ?? {});
151
+ this.client = new LambdaClient({ maxAttempts: 1, ...this.clientConfig });
365
152
  const output = await this.client.send(new InvokeCommand({
366
153
  FunctionName: this.functionName,
367
154
  InvocationType: "RequestResponse",
@@ -379,55 +166,51 @@ export default class LambderInvokeCaller {
379
166
  }
380
167
  return { functionError: output.FunctionError ?? null, result };
381
168
  }
382
- /** Delivers one event, serialized exactly once; a rejected transport is a network or timeout failure. */
383
- async deliver(event, eventJson, options) {
384
- const abort = abortAfter(options.timeoutMs ?? this.timeoutMs, options.signal);
169
+ /** Delivers one event, serialized exactly once; an event over the invoke cap, a rejected transport, or one that answered after the call was given up on, is a failure. */
170
+ async deliverEvent(event, eventJson, options) {
171
+ // Measured here rather than on the API path alone, because every
172
+ // caller of this one sends the same bytes. A path that skips the cap
173
+ // gets the SDK's RequestEntityTooLargeException back instead, which
174
+ // classifies as `protocol` and names neither the size nor the cap.
175
+ const bytes = Buffer.byteLength(eventJson, "utf8");
176
+ if (bytes > LAMBDER_INVOKE_MAX_EVENT_BYTES) {
177
+ return { failed: {
178
+ reason: 'payloadTooLarge', bytes,
179
+ detail: `the event is ${bytes} bytes, over the ${LAMBDER_INVOKE_MAX_EVENT_BYTES} byte invoke cap`,
180
+ } };
181
+ }
182
+ // The same wiring the browser caller uses, so the two cannot drift on
183
+ // what a late or abandoned call means.
184
+ const abort = createCallAbort({ timeoutMs: options.timeoutMs ?? this.timeoutMs, signal: options.signal });
385
185
  try {
186
+ // A call the site has already given up on does not reach the
187
+ // transport: honouring the signal is the transport's obligation
188
+ // and not every transport does.
189
+ const refused = abort.abortFailure("beforeSending");
190
+ if (refused)
191
+ return { failed: { reason: refused.reason, cause: refused.error, detail: refused.error.message } };
386
192
  const sent = await this.transport(event, { functionName: this.functionName, eventJson, signal: abort.signal });
193
+ // An answer that arrives after the abort is not a success: a
194
+ // transport that ignores the signal resolves late, and believing
195
+ // it would report ok on a 20ms timeoutMs at 300ms, handing the
196
+ // call site data it had already abandoned.
197
+ const late = abort.abortFailure("afterAnswering");
198
+ if (late)
199
+ return { failed: { reason: late.reason, cause: late.error, detail: late.error.message } };
387
200
  return { sent };
388
201
  }
389
202
  catch (err) {
390
- const cause = err instanceof Error ? err : new Error(String(err));
391
- return { failed: { reason: abort.timedOut() ? 'timeout' : 'network', cause } };
203
+ const cause = coerceToError(err, "the invoke failed");
204
+ // The caller's own timeout wins, since only it knows about that;
205
+ // otherwise the rejection says what it was.
206
+ return { failed: { reason: abort.timedOut() ? 'timeout' : classifyDeliveryFailure(cause), cause } };
392
207
  }
393
208
  finally {
394
- abort.clear();
209
+ abort.detach();
395
210
  }
396
211
  }
397
- /** The function's answer as an HTTP result; throws when it is not one, or its compressed body cannot be restored. */
398
- async decodeHttpResult(result) {
399
- if (!result || typeof result !== "object" || typeof result.statusCode !== "number") {
400
- throw new Error("the function did not answer with an HTTP response object; is it a Lambder app?");
401
- }
402
- const raw = result;
403
- const headers = {};
404
- for (const [key, value] of Object.entries(raw.headers ?? {}))
405
- headers[key.toLowerCase()] = value;
406
- for (const [key, values] of Object.entries(raw.multiValueHeaders ?? {}))
407
- headers[key.toLowerCase()] = values.join(", ");
408
- let body = raw.body ? Buffer.from(raw.body, raw.isBase64Encoded ? "base64" : "utf8") : Buffer.alloc(0);
409
- const encoding = headers["content-encoding"]?.trim().toLowerCase();
410
- if (encoding === "br" || encoding === "gzip") {
411
- // restoreBytes, not restoreText: a route may answer compressed
412
- // binary (a wasm module, anything it forced compression on), and
413
- // decoding that as UTF-8 first would replace every byte that is
414
- // not valid UTF-8 and hand back a silently different body.
415
- body = await restoreBytes(body, encoding, { maxBytes: this.maxResponsePayloadBytes });
416
- }
417
- else if (encoding) {
418
- throw new Error(`the answer carries an unsupported Content-Encoding "${encoding}"`);
419
- }
420
- return {
421
- statusCode: raw.statusCode,
422
- headers,
423
- cookies: raw.cookies ?? [],
424
- body,
425
- text: () => body.toString("utf8"),
426
- json: () => JSON.parse(body.toString("utf8")),
427
- };
428
- }
429
212
  /** Builds the failure and its error, reports it once, and hands it back. */
430
- async fail(apiName, init) {
213
+ async failureOutcome(apiName, init) {
431
214
  const logList = init.logList ?? [];
432
215
  const cause = init.crash ? errorFromCrashDetail(init.crash)
433
216
  : init.functionError ? errorFromFunctionError(init.functionError)
@@ -448,11 +231,16 @@ export default class LambderInvokeCaller {
448
231
  bytes: init.bytes,
449
232
  cause,
450
233
  });
234
+ // FailureInit's arms mirror the outcome's, so each reason's evidence
235
+ // was already demanded at the site that chose the reason; the
236
+ // assembly is one object either way, and this is where it is named as
237
+ // the arm it is rather than written out five times.
451
238
  const failure = {
452
239
  ok: false,
453
240
  reason: init.reason,
454
241
  error,
455
242
  logList,
243
+ cookies: init.cookies ?? [],
456
244
  ...(init.status !== undefined ? { status: init.status } : {}),
457
245
  ...(init.errorMessage !== undefined ? { errorMessage: init.errorMessage } : {}),
458
246
  ...(init.retryAfterSeconds !== undefined ? { retryAfterSeconds: init.retryAfterSeconds } : {}),
@@ -460,9 +248,8 @@ export default class LambderInvokeCaller {
460
248
  ...(init.crash !== undefined ? { crash: init.crash } : {}),
461
249
  ...(init.functionError !== undefined ? { functionError: init.functionError } : {}),
462
250
  ...(init.bytes !== undefined ? { bytes: init.bytes } : {}),
251
+ ...(init.response !== undefined ? { response: init.response } : {}),
463
252
  };
464
- if (init.response !== undefined)
465
- failure.response = init.response;
466
253
  error.outcome = failure;
467
254
  if (this.onFailure) {
468
255
  // A reporting hook that breaks must not turn apiOutcome() into a
@@ -502,7 +289,6 @@ export default class LambderInvokeCaller {
502
289
  // reported. So a throw here is an 'unknown' failure like any other.
503
290
  let event;
504
291
  let eventJson;
505
- let bytes;
506
292
  try {
507
293
  // Provider values underneath, per-call values on top.
508
294
  const provided = this.guardInputsProvider
@@ -514,13 +300,11 @@ export default class LambderInvokeCaller {
514
300
  // into the envelope. Compressed when enabled and the JSON reaches
515
301
  // the threshold; `compressRequest` overrides both ways.
516
302
  const payloadJson = payload !== undefined ? JSON.stringify(payload) : undefined;
517
- const compressionMinBytes = options.compressRequest === true ? 0
518
- : options.compressRequest === false ? null
519
- : this.requestCompression?.minBytes ?? null;
303
+ const compressionMinBytes = resolveRequestCompressionMinBytes(options.compressRequest, this.requestCompression);
520
304
  const compressed = compressionMinBytes !== null && payloadJson !== undefined
521
305
  ? await compressPayloadBrotli(payloadJson, compressionMinBytes, this.requestCompression?.quality ?? DEFAULT_INVOKE_REQUEST_COMPRESSION_SETTINGS.quality)
522
306
  : null;
523
- event = synthesizeHttpEvent({
307
+ event = synthesizeLambdaHttpEvent({
524
308
  method: "POST",
525
309
  path: this.apiPath,
526
310
  host: this.host,
@@ -537,33 +321,26 @@ export default class LambderInvokeCaller {
537
321
  guardInputs,
538
322
  idempotencyKey: options.idempotencyKey,
539
323
  }),
540
- });
324
+ }, { invoke: true });
541
325
  // Serialized once here; the size guard and the SDK transport both use it.
542
326
  eventJson = JSON.stringify(event);
543
- bytes = Buffer.byteLength(eventJson, "utf8");
544
327
  }
545
328
  catch (err) {
546
- return await this.fail(apiName, { reason: 'unknown', cause: err instanceof Error ? err : new Error(String(err)) });
329
+ return await this.failureOutcome(apiName, { reason: 'unknown', cause: coerceToError(err, "the call could not be built") });
547
330
  }
548
- if (bytes > LAMBDER_INVOKE_MAX_EVENT_BYTES) {
549
- return await this.fail(apiName, {
550
- reason: 'payloadTooLarge', bytes,
551
- detail: `the event is ${bytes} bytes, over the ${LAMBDER_INVOKE_MAX_EVENT_BYTES} byte invoke cap`,
552
- });
553
- }
554
- const delivery = await this.deliver(event, eventJson, options);
331
+ const delivery = await this.deliverEvent(event, eventJson, options);
555
332
  if ("failed" in delivery)
556
- return await this.fail(apiName, delivery.failed);
333
+ return await this.failureOutcome(apiName, delivery.failed);
557
334
  if (delivery.sent.functionError) {
558
- return await this.fail(apiName, { reason: 'crash', functionError: parseFunctionError(delivery.sent.result) });
335
+ return await this.failureOutcome(apiName, { reason: 'crash', functionError: parseFunctionError(delivery.sent.result) });
559
336
  }
560
337
  let http;
561
338
  try {
562
- http = await this.decodeHttpResult(delivery.sent.result);
339
+ http = await decodeLambdaHttpResult(delivery.sent.result, this.maxResponsePayloadBytes);
563
340
  }
564
341
  catch (err) {
565
- const cause = err instanceof Error ? err : new Error(String(err));
566
- return await this.fail(apiName, { reason: 'protocol', cause, detail: cause.message });
342
+ const cause = coerceToError(err, "the answer could not be decoded");
343
+ return await this.failureOutcome(apiName, { reason: 'protocol', cause, detail: cause.message });
567
344
  }
568
345
  const outcome = await resolveApiOutcome({
569
346
  status: http.statusCode,
@@ -571,37 +348,64 @@ export default class LambderInvokeCaller {
571
348
  json: async () => http.json(),
572
349
  text: async () => http.text(),
573
350
  });
574
- const logList = outcome.response?.logList ?? [];
351
+ // Every answer's logs, from the one field the mapping puts them on:
352
+ // an envelope's, a 500 body's, and a rejected input's, which the
353
+ // callee writes onto the validation body as it does onto a success.
354
+ const logList = outcome.logList ?? [];
575
355
  await this.surfaceLogs(apiName, logList);
356
+ // The answer's Set-Cookie values, so a session the callee rotated or
357
+ // cleared is visible to whoever is carrying it.
358
+ const cookies = http.cookies;
576
359
  // The declared output, by the callee's own typing: res.api(null) compiles
577
360
  // only for an output that allows null or beside a reason (an errorMessage
578
361
  // is a failure below; a message-only null is the callee's contract to keep).
579
362
  if (outcome.ok)
580
- return { ok: true, payload: (outcome.payload ?? null), response: outcome.response, logList };
581
- // A 404 text page is what a callee answers when apiPath does not
582
- // match: the one misconfiguration every first integration hits.
583
- const detail = http.statusCode === 404 && outcome.reason === 'server'
584
- ? `no API at ${this.apiPath} on ${this.functionName} (HTTP 404): does apiPath match the callee's?`
585
- : undefined;
586
- return await this.fail(apiName, {
363
+ return { ok: true, payload: (outcome.payload ?? null), response: outcome.response, logList, cookies };
364
+ const shared = { status: outcome.status, retryAfterSeconds: outcome.retryAfterSeconds, logList, cookies };
365
+ // Each failure reason carries different evidence, and the outcome
366
+ // union says which: a rejected input has its issues and no envelope,
367
+ // an envelope refusal has the envelope and no Error.
368
+ if (outcome.reason === 'validation') {
369
+ return await this.failureOutcome(apiName, { ...shared, reason: 'validation', zodError: outcome.zodError });
370
+ }
371
+ if (outcome.reason === 'server') {
372
+ // A 404 text page is what a callee answers when apiPath does not
373
+ // match: the one misconfiguration every first integration hits.
374
+ const detail = http.statusCode === 404
375
+ ? `no API at ${this.apiPath} on ${this.functionName} (HTTP 404): does apiPath match the callee's?`
376
+ : undefined;
377
+ return await this.failureOutcome(apiName, {
378
+ ...shared,
379
+ reason: 'server',
380
+ errorMessage: outcome.errorMessage,
381
+ response: outcome.response,
382
+ crash: outcome.response?.crash,
383
+ cause: outcome.error,
384
+ detail,
385
+ });
386
+ }
387
+ return await this.failureOutcome(apiName, {
388
+ ...shared,
587
389
  reason: outcome.reason,
588
- status: outcome.status,
589
390
  errorMessage: outcome.errorMessage,
590
- retryAfterSeconds: outcome.retryAfterSeconds,
591
- zodError: outcome.zodError,
592
391
  response: outcome.response,
593
- crash: outcome.response?.crash,
594
- logList,
595
- cause: outcome.error,
596
- detail,
392
+ crash: outcome.response.crash,
597
393
  });
598
394
  }
599
395
  /**
600
396
  * Full-fidelity call: resolves to a discriminated LambderInvokeOutcome
601
397
  * instead of throwing. Never throws; for sites that degrade gracefully.
398
+ *
399
+ * The output is computed from the contract in the return type rather than
400
+ * taken as a type parameter, so a call site cannot replace it by
401
+ * annotating what it assigns to.
602
402
  */
603
- async apiOutcome(apiName, payload, ...rest) {
604
- return await this.dispatch(apiName, payload, rest[0]);
403
+ async apiOutcome(apiName, ...rest) {
404
+ // The tuple is a conditional type on an unresolved TApiName, so its
405
+ // elements read as unknown from inside; the contract shaped them on
406
+ // the way in, which is where the guarantee belongs.
407
+ const [payload, options] = rest;
408
+ return await this.dispatch(apiName, payload, options);
605
409
  }
606
410
  /**
607
411
  * The declared output, or a thrown LambderInvokeError carrying the
@@ -612,8 +416,9 @@ export default class LambderInvokeCaller {
612
416
  * reason (LambderApiAnswer), so a nullable output is the one place null
613
417
  * arrives.
614
418
  */
615
- async api(apiName, payload, ...rest) {
616
- const outcome = await this.dispatch(apiName, payload, rest[0]);
419
+ async api(apiName, ...rest) {
420
+ const [payload, options] = rest;
421
+ const outcome = await this.dispatch(apiName, payload, options);
617
422
  if (!outcome.ok)
618
423
  throw outcome.error;
619
424
  return outcome.payload;
@@ -627,7 +432,7 @@ export default class LambderInvokeCaller {
627
432
  async request(init) {
628
433
  const method = (init.method ?? "GET").toUpperCase();
629
434
  const name = `${method} ${init.path}`;
630
- const event = synthesizeHttpEvent({
435
+ const event = synthesizeLambdaHttpEvent({
631
436
  method,
632
437
  path: init.path,
633
438
  query: init.query,
@@ -636,19 +441,19 @@ export default class LambderInvokeCaller {
636
441
  clientIp: init.clientIp,
637
442
  cookies: init.cookies,
638
443
  body: init.body,
639
- });
640
- const delivery = await this.deliver(event, JSON.stringify(event), init);
444
+ }, { invoke: true });
445
+ const delivery = await this.deliverEvent(event, JSON.stringify(event), init);
641
446
  if ("failed" in delivery)
642
- throw (await this.fail(name, delivery.failed)).error;
447
+ throw (await this.failureOutcome(name, delivery.failed)).error;
643
448
  if (delivery.sent.functionError) {
644
- throw (await this.fail(name, { reason: 'crash', functionError: parseFunctionError(delivery.sent.result) })).error;
449
+ throw (await this.failureOutcome(name, { reason: 'crash', functionError: parseFunctionError(delivery.sent.result) })).error;
645
450
  }
646
451
  try {
647
- return await this.decodeHttpResult(delivery.sent.result);
452
+ return await decodeLambdaHttpResult(delivery.sent.result, this.maxResponsePayloadBytes);
648
453
  }
649
454
  catch (err) {
650
- const cause = err instanceof Error ? err : new Error(String(err));
651
- throw (await this.fail(name, { reason: 'protocol', cause, detail: cause.message })).error;
455
+ const cause = coerceToError(err, "the answer could not be decoded");
456
+ throw (await this.failureOutcome(name, { reason: 'protocol', cause, detail: cause.message })).error;
652
457
  }
653
458
  }
654
459
  }