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.
- package/README.md +8 -3
- package/dist/client/LambderCaller.d.ts +8 -83
- package/dist/client/LambderCaller.js +32 -64
- package/dist/client/LambderMSW.js +4 -4
- package/dist/client.d.ts +5 -3
- package/dist/client.js +3 -1
- package/dist/core/Lambder.js +10 -3
- package/dist/core/LambderContext.d.ts +6 -5
- package/dist/core/LambderContext.js +20 -12
- package/dist/core/LambderResolver.d.ts +7 -5
- package/dist/core/LambderResolver.js +7 -3
- package/dist/core/LambderResponseBuilder.d.ts +17 -3
- package/dist/core/LambderResponseBuilder.js +2 -2
- package/dist/index.d.ts +10 -6
- package/dist/index.js +6 -2
- package/dist/invoke/LambderInvokeCaller.d.ts +322 -0
- package/dist/invoke/LambderInvokeCaller.js +654 -0
- package/dist/session/LambderSessionManager.d.ts +5 -1
- package/dist/session/LambderSessionManager.js +26 -13
- package/dist/shared/LambderApiContract.d.ts +9 -0
- package/dist/shared/LambderApiOutcome.d.ts +69 -0
- package/dist/shared/LambderApiOutcome.js +79 -0
- package/dist/shared/LambderCallOptions.d.ts +71 -0
- package/dist/shared/LambderCallOptions.js +16 -0
- package/dist/shared/LambderCompressionCodec.d.ts +45 -8
- package/dist/shared/LambderCompressionCodec.js +44 -14
- package/dist/shared/LambderCrashDetail.d.ts +48 -0
- package/dist/shared/LambderCrashDetail.js +78 -0
- package/dist/shared/LambderRequestPayload.d.ts +46 -23
- package/dist/shared/LambderRequestPayload.js +47 -31
- package/dist/stores/LambderDdbCache.d.ts +7 -2
- package/dist/stores/LambderDdbCache.js +30 -12
- package/dist/stores/LambderDdbIdempotency.d.ts +7 -2
- package/dist/stores/LambderDdbIdempotency.js +26 -10
- package/dist/stores/LambderDdbRateLimiter.d.ts +7 -2
- package/dist/stores/LambderDdbRateLimiter.js +16 -4
- package/dist/stores/LambderDdbSdk.d.ts +20 -0
- package/dist/stores/LambderDdbSdk.js +31 -0
- 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
|
|
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
|