lambder 5.1.3 → 6.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 (39) hide show
  1. package/README.md +8 -3
  2. package/dist/client/LambderCaller.d.ts +8 -83
  3. package/dist/client/LambderCaller.js +32 -64
  4. package/dist/client/LambderMSW.js +4 -4
  5. package/dist/client.d.ts +5 -3
  6. package/dist/client.js +3 -1
  7. package/dist/core/Lambder.js +10 -3
  8. package/dist/core/LambderContext.d.ts +6 -5
  9. package/dist/core/LambderContext.js +20 -12
  10. package/dist/core/LambderResolver.d.ts +7 -5
  11. package/dist/core/LambderResolver.js +7 -3
  12. package/dist/core/LambderResponseBuilder.d.ts +17 -3
  13. package/dist/core/LambderResponseBuilder.js +2 -2
  14. package/dist/index.d.ts +10 -6
  15. package/dist/index.js +6 -2
  16. package/dist/invoke/LambderInvokeCaller.d.ts +322 -0
  17. package/dist/invoke/LambderInvokeCaller.js +654 -0
  18. package/dist/session/LambderSessionManager.d.ts +5 -1
  19. package/dist/session/LambderSessionManager.js +26 -13
  20. package/dist/shared/LambderApiContract.d.ts +9 -0
  21. package/dist/shared/LambderApiOutcome.d.ts +69 -0
  22. package/dist/shared/LambderApiOutcome.js +79 -0
  23. package/dist/shared/LambderCallOptions.d.ts +71 -0
  24. package/dist/shared/LambderCallOptions.js +16 -0
  25. package/dist/shared/LambderCompressionCodec.d.ts +45 -8
  26. package/dist/shared/LambderCompressionCodec.js +44 -14
  27. package/dist/shared/LambderCrashDetail.d.ts +48 -0
  28. package/dist/shared/LambderCrashDetail.js +78 -0
  29. package/dist/shared/LambderRequestPayload.d.ts +46 -23
  30. package/dist/shared/LambderRequestPayload.js +47 -31
  31. package/dist/stores/LambderDdbCache.d.ts +7 -2
  32. package/dist/stores/LambderDdbCache.js +30 -12
  33. package/dist/stores/LambderDdbIdempotency.d.ts +7 -2
  34. package/dist/stores/LambderDdbIdempotency.js +26 -10
  35. package/dist/stores/LambderDdbRateLimiter.d.ts +7 -2
  36. package/dist/stores/LambderDdbRateLimiter.js +16 -4
  37. package/dist/stores/LambderDdbSdk.d.ts +20 -0
  38. package/dist/stores/LambderDdbSdk.js +31 -0
  39. package/package.json +11 -6
@@ -0,0 +1,654 @@
1
+ /**
2
+ * Calling a Lambder app from another lambda, or from any server code that
3
+ * holds AWS credentials and lambda:InvokeFunction on it.
4
+ *
5
+ * API Gateway delivers an HTTP request to a Lambder app as a JSON event and
6
+ * takes a JSON response object back; a direct InvokeCommand carries JSON in
7
+ * both directions too. So this caller builds the payload-format-2.0 event
8
+ * API Gateway would have built, invokes the function with it, and reads the
9
+ * response object Lambder returns. The callee is an unmodified Lambder app,
10
+ * and everything it offers over HTTP (zod validation, the inferred contract,
11
+ * refusals, guards, idempotency keys, Brotli answers, logList, the crash
12
+ * detail its global error handler chooses to send) applies unchanged. The
13
+ * callee tells an invoke from a browser only by the x-lambder-invoke header,
14
+ * which is a marker for guards and hooks, never an authorization: the IAM
15
+ * grant is that.
16
+ *
17
+ * Server-only (zlib, the Lambda SDK), so it is exported from the root entry
18
+ * and never from lambder/client. The SDK is an optional peer dependency
19
+ * loaded on the first call; the `transport` option replaces it, and
20
+ * LambderInvokeCaller.localTransport runs a callee's handler in-process for
21
+ * tests.
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";
35
+ /**
36
+ * Lambda caps a synchronous invoke's request and its response at about 6MB;
37
+ * the same guard threshold finalizeResponse applies to an answer, applied
38
+ * here to the event before it is sent.
39
+ */
40
+ 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
+ /**
252
+ * @typeParam TContract - The callee's API contract (`typeof lambder.ApiContract`, imported type-only), for typed names, payloads, results and guard inputs.
253
+ * @typeParam TProvidedGuards - Guard names guardInputsProvider covers; those APIs' options argument becomes optional.
254
+ */
255
+ export default class LambderInvokeCaller {
256
+ functionName;
257
+ apiPath;
258
+ apiVersion;
259
+ host;
260
+ requestCompression;
261
+ maxResponsePayloadBytes;
262
+ timeoutMs;
263
+ onLogList;
264
+ onFailure;
265
+ guardInputsProvider;
266
+ sessionTokenCookieKey;
267
+ transport;
268
+ clientConfig;
269
+ client;
270
+ sdk;
271
+ constructor(options) {
272
+ const { functionName, client, clientConfig, apiPath, apiVersion, host, requestCompression, maxResponsePayloadBytes, timeoutMs, onLogList, onFailure, sessionTokenCookieKey, transport, guardInputsProvider, } = options;
273
+ if (!functionName?.trim())
274
+ throw new Error("LambderInvokeCaller: functionName is required");
275
+ this.functionName = functionName;
276
+ this.client = client;
277
+ this.clientConfig = clientConfig;
278
+ this.apiPath = apiPath ?? "/api";
279
+ this.apiVersion = apiVersion;
280
+ this.host = host ?? functionName;
281
+ // `?? false`: like the browser caller, off unless asked for.
282
+ 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
+ }
287
+ this.timeoutMs = timeoutMs;
288
+ this.onLogList = onLogList;
289
+ this.onFailure = onFailure;
290
+ this.guardInputsProvider = guardInputsProvider;
291
+ this.sessionTokenCookieKey = sessionTokenCookieKey ?? DEFAULT_SESSION_TOKEN_COOKIE_KEY;
292
+ this.transport = transport ?? ((_event, { eventJson, signal }) => this.invokeThroughSdk(eventJson, signal));
293
+ }
294
+ /**
295
+ * The event api() would send for this call, with a plain payload. For
296
+ * tests and boot checks that hand a built package an event file.
297
+ */
298
+ static createEvent(init) {
299
+ const host = init.host ?? "lambder-invoke";
300
+ const tokenCookieKey = init.sessionTokenCookieKey ?? DEFAULT_SESSION_TOKEN_COOKIE_KEY;
301
+ return synthesizeHttpEvent({
302
+ method: "POST",
303
+ path: init.apiPath ?? "/api",
304
+ host,
305
+ headers: init.headers,
306
+ clientIp: init.clientIp,
307
+ cookies: sessionCookies(init.session, tokenCookieKey),
308
+ body: buildEnvelopeJson({
309
+ apiName: init.apiName,
310
+ version: init.apiVersion,
311
+ csrf: init.session?.csrf,
312
+ siteHost: host,
313
+ payloadJson: init.payload !== undefined ? JSON.stringify(init.payload) : undefined,
314
+ guardInputs: init.guardInputs,
315
+ idempotencyKey: init.idempotencyKey,
316
+ }),
317
+ });
318
+ }
319
+ /**
320
+ * A transport that runs a callee's handler in this process, the way
321
+ * Lambda would: a thrown error becomes a FunctionError payload. For
322
+ * tests that want the real handlers behind the real envelope.
323
+ */
324
+ 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
+ };
341
+ try {
342
+ return { functionError: null, result: await handler(event, lambdaContext) };
343
+ }
344
+ catch (err) {
345
+ const error = err instanceof Error ? err : new Error(String(err));
346
+ return {
347
+ functionError: "Unhandled",
348
+ result: { errorType: error.name, errorMessage: error.message, trace: (error.stack ?? "").split("\n") },
349
+ };
350
+ }
351
+ };
352
+ }
353
+ loadSdk() {
354
+ if (!this.sdk) {
355
+ this.sdk = import("@aws-sdk/client-lambda").catch(() => {
356
+ throw new Error("LambderInvokeCaller requires @aws-sdk/client-lambda: npm install @aws-sdk/client-lambda");
357
+ });
358
+ }
359
+ return this.sdk;
360
+ }
361
+ async invokeThroughSdk(eventJson, signal) {
362
+ const { LambdaClient, InvokeCommand } = await this.loadSdk();
363
+ if (!this.client)
364
+ this.client = new LambdaClient(this.clientConfig ?? {});
365
+ const output = await this.client.send(new InvokeCommand({
366
+ FunctionName: this.functionName,
367
+ InvocationType: "RequestResponse",
368
+ Payload: Buffer.from(eventJson, "utf8"),
369
+ }), signal ? { abortSignal: signal } : undefined);
370
+ const text = output.Payload ? Buffer.from(output.Payload).toString("utf8") : "";
371
+ let result = undefined;
372
+ if (text) {
373
+ try {
374
+ result = JSON.parse(text);
375
+ }
376
+ catch {
377
+ result = text;
378
+ }
379
+ }
380
+ return { functionError: output.FunctionError ?? null, result };
381
+ }
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);
385
+ try {
386
+ const sent = await this.transport(event, { functionName: this.functionName, eventJson, signal: abort.signal });
387
+ return { sent };
388
+ }
389
+ catch (err) {
390
+ const cause = err instanceof Error ? err : new Error(String(err));
391
+ return { failed: { reason: abort.timedOut() ? 'timeout' : 'network', cause } };
392
+ }
393
+ finally {
394
+ abort.clear();
395
+ }
396
+ }
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
+ /** Builds the failure and its error, reports it once, and hands it back. */
430
+ async fail(apiName, init) {
431
+ const logList = init.logList ?? [];
432
+ const cause = init.crash ? errorFromCrashDetail(init.crash)
433
+ : init.functionError ? errorFromFunctionError(init.functionError)
434
+ : init.cause;
435
+ const detail = init.detail ?? describeFailure(init);
436
+ const error = new LambderInvokeError({
437
+ message: `${this.functionName} ${apiName} failed (${init.reason}): ${detail}`,
438
+ reason: init.reason,
439
+ apiName,
440
+ functionName: this.functionName,
441
+ status: init.status,
442
+ errorMessage: init.errorMessage,
443
+ crash: init.crash,
444
+ functionError: init.functionError,
445
+ logList,
446
+ zodError: init.zodError,
447
+ retryAfterSeconds: init.retryAfterSeconds,
448
+ bytes: init.bytes,
449
+ cause,
450
+ });
451
+ const failure = {
452
+ ok: false,
453
+ reason: init.reason,
454
+ error,
455
+ logList,
456
+ ...(init.status !== undefined ? { status: init.status } : {}),
457
+ ...(init.errorMessage !== undefined ? { errorMessage: init.errorMessage } : {}),
458
+ ...(init.retryAfterSeconds !== undefined ? { retryAfterSeconds: init.retryAfterSeconds } : {}),
459
+ ...(init.zodError !== undefined ? { zodError: init.zodError } : {}),
460
+ ...(init.crash !== undefined ? { crash: init.crash } : {}),
461
+ ...(init.functionError !== undefined ? { functionError: init.functionError } : {}),
462
+ ...(init.bytes !== undefined ? { bytes: init.bytes } : {}),
463
+ };
464
+ if (init.response !== undefined)
465
+ failure.response = init.response;
466
+ error.outcome = failure;
467
+ if (this.onFailure) {
468
+ // A reporting hook that breaks must not turn apiOutcome() into a
469
+ // throwing call, nor replace the failure it was told about.
470
+ try {
471
+ await this.onFailure(failure, { apiName, functionName: this.functionName });
472
+ }
473
+ catch (err) {
474
+ console.error(`[lambder invoke] onFailure threw for ${this.functionName} ${apiName}`, err);
475
+ }
476
+ }
477
+ return failure;
478
+ }
479
+ async surfaceLogs(apiName, logList) {
480
+ if (!logList.length)
481
+ return;
482
+ if (this.onLogList) {
483
+ try {
484
+ await this.onLogList(apiName, logList);
485
+ }
486
+ catch (err) {
487
+ console.error(`[lambder invoke] onLogList threw for ${this.functionName} ${apiName}`, err);
488
+ }
489
+ return;
490
+ }
491
+ for (const entry of logList)
492
+ console.log(`[lambder invoke] ${this.functionName} ${apiName}`, entry);
493
+ }
494
+ /** One call, one outcome. Never throws; api() is what throws. */
495
+ async dispatch(apiName, payload, options = {}) {
496
+ // Everything that happens before the event leaves: the guardInputs
497
+ // provider, the payload's JSON and its compression. All of it can
498
+ // throw on the caller's own inputs (a provider that rejects, a
499
+ // payload holding a cycle or a BigInt), and none of it may escape:
500
+ // apiOutcome() promises an outcome, api() promises a
501
+ // LambderInvokeError, and onFailure is the one place failures are
502
+ // reported. So a throw here is an 'unknown' failure like any other.
503
+ let event;
504
+ let eventJson;
505
+ let bytes;
506
+ try {
507
+ // Provider values underneath, per-call values on top.
508
+ const provided = this.guardInputsProvider
509
+ ? await this.guardInputsProvider(apiName)
510
+ : undefined;
511
+ const guardInputs = mergeGuardInputs(provided, options.guardInputs);
512
+ // The payload is serialized once: the compression decision needs
513
+ // its JSON, and when it goes plainly that same JSON is spliced
514
+ // into the envelope. Compressed when enabled and the JSON reaches
515
+ // the threshold; `compressRequest` overrides both ways.
516
+ 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;
520
+ const compressed = compressionMinBytes !== null && payloadJson !== undefined
521
+ ? await compressPayloadBrotli(payloadJson, compressionMinBytes, this.requestCompression?.quality ?? DEFAULT_INVOKE_REQUEST_COMPRESSION_SETTINGS.quality)
522
+ : null;
523
+ event = synthesizeHttpEvent({
524
+ method: "POST",
525
+ path: this.apiPath,
526
+ host: this.host,
527
+ headers: options.headers,
528
+ clientIp: options.clientIp,
529
+ cookies: sessionCookies(options.session, this.sessionTokenCookieKey),
530
+ body: buildEnvelopeJson({
531
+ apiName,
532
+ version: this.apiVersion,
533
+ csrf: options.session?.csrf,
534
+ siteHost: this.host,
535
+ payloadJson: compressed ? undefined : payloadJson,
536
+ compressed,
537
+ guardInputs,
538
+ idempotencyKey: options.idempotencyKey,
539
+ }),
540
+ });
541
+ // Serialized once here; the size guard and the SDK transport both use it.
542
+ eventJson = JSON.stringify(event);
543
+ bytes = Buffer.byteLength(eventJson, "utf8");
544
+ }
545
+ catch (err) {
546
+ return await this.fail(apiName, { reason: 'unknown', cause: err instanceof Error ? err : new Error(String(err)) });
547
+ }
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);
555
+ if ("failed" in delivery)
556
+ return await this.fail(apiName, delivery.failed);
557
+ if (delivery.sent.functionError) {
558
+ return await this.fail(apiName, { reason: 'crash', functionError: parseFunctionError(delivery.sent.result) });
559
+ }
560
+ let http;
561
+ try {
562
+ http = await this.decodeHttpResult(delivery.sent.result);
563
+ }
564
+ 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 });
567
+ }
568
+ const outcome = await resolveApiOutcome({
569
+ status: http.statusCode,
570
+ header: (name) => http.headers[name.toLowerCase()] ?? null,
571
+ json: async () => http.json(),
572
+ text: async () => http.text(),
573
+ });
574
+ const logList = outcome.response?.logList ?? [];
575
+ await this.surfaceLogs(apiName, logList);
576
+ // The declared output, by the callee's own typing: res.api(null) compiles
577
+ // only for an output that allows null or beside a reason (an errorMessage
578
+ // is a failure below; a message-only null is the callee's contract to keep).
579
+ 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, {
587
+ reason: outcome.reason,
588
+ status: outcome.status,
589
+ errorMessage: outcome.errorMessage,
590
+ retryAfterSeconds: outcome.retryAfterSeconds,
591
+ zodError: outcome.zodError,
592
+ response: outcome.response,
593
+ crash: outcome.response?.crash,
594
+ logList,
595
+ cause: outcome.error,
596
+ detail,
597
+ });
598
+ }
599
+ /**
600
+ * Full-fidelity call: resolves to a discriminated LambderInvokeOutcome
601
+ * instead of throwing. Never throws; for sites that degrade gracefully.
602
+ */
603
+ async apiOutcome(apiName, payload, ...rest) {
604
+ return await this.dispatch(apiName, payload, rest[0]);
605
+ }
606
+ /**
607
+ * The declared output, or a thrown LambderInvokeError carrying the
608
+ * outcome. A failed dependency is a failed request: the throw reaches the
609
+ * app's global error handler with the callee's error as its cause. The
610
+ * result is the callee's output type as it declared it: the resolver
611
+ * only lets a handler answer null when the output allows it or beside a
612
+ * reason (LambderApiAnswer), so a nullable output is the one place null
613
+ * arrives.
614
+ */
615
+ async api(apiName, payload, ...rest) {
616
+ const outcome = await this.dispatch(apiName, payload, rest[0]);
617
+ if (!outcome.ok)
618
+ throw outcome.error;
619
+ return outcome.payload;
620
+ }
621
+ /**
622
+ * Any route of the callee, untyped: the synthesized request and the
623
+ * decoded answer, whatever its status. Throws a LambderInvokeError only
624
+ * when no HTTP answer came back (a rejected invoke, a FunctionError, a
625
+ * non-HTTP answer); those go through onFailure like an API call's.
626
+ */
627
+ async request(init) {
628
+ const method = (init.method ?? "GET").toUpperCase();
629
+ const name = `${method} ${init.path}`;
630
+ const event = synthesizeHttpEvent({
631
+ method,
632
+ path: init.path,
633
+ query: init.query,
634
+ host: this.host,
635
+ headers: init.headers,
636
+ clientIp: init.clientIp,
637
+ cookies: init.cookies,
638
+ body: init.body,
639
+ });
640
+ const delivery = await this.deliver(event, JSON.stringify(event), init);
641
+ if ("failed" in delivery)
642
+ throw (await this.fail(name, delivery.failed)).error;
643
+ if (delivery.sent.functionError) {
644
+ throw (await this.fail(name, { reason: 'crash', functionError: parseFunctionError(delivery.sent.result) })).error;
645
+ }
646
+ try {
647
+ return await this.decodeHttpResult(delivery.sent.result);
648
+ }
649
+ 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;
652
+ }
653
+ }
654
+ }
@@ -78,7 +78,9 @@ export default class LambderSessionManager {
78
78
  private sessionSalt;
79
79
  private partitionKey;
80
80
  private sortKey;
81
- private ddbDocumentClient;
81
+ private tableRegion;
82
+ /** The document client and the SDK it came from, created the first time the table is touched. */
83
+ private readyPromise;
82
84
  private enableSlidingExpiration;
83
85
  private slidingWriteIntervalSeconds;
84
86
  private dataRefresh;
@@ -94,6 +96,8 @@ export default class LambderSessionManager {
94
96
  dataRefresh?: LambderSessionDataRefreshConfig;
95
97
  compression?: LambderCompressionOption;
96
98
  });
99
+ /** The SDK and the client, loaded and created the first time the table is touched (see LambderDdbSdk). */
100
+ private ready;
97
101
  private sessionUserKeyHasher;
98
102
  /**
99
103
  * At-rest hash for the bearer secrets (session sort-key secret, CSRF