lambder 5.1.2 → 6.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.
- 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.d.ts +2 -2
- 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/policies/LambderApiGuards.d.ts +8 -8
- package/dist/policies/LambderApiRateLimits.d.ts +3 -3
- 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 +8 -3
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A crash, described for a caller that is allowed to see it.
|
|
3
|
+
*
|
|
4
|
+
* A global error handler decides what a failed request learns about the
|
|
5
|
+
* failure. A browser gets a generic message; a trusted caller (another
|
|
6
|
+
* lambda invoking this one, a developer holding a debug cookie) can be
|
|
7
|
+
* handed the whole thing: the error's name, message and stack, its cause
|
|
8
|
+
* chain, and where it happened, so the caller can store it in its own
|
|
9
|
+
* error log and point at the right CloudWatch stream. The envelope carries
|
|
10
|
+
* it in the `crash` field beside errorMessage; LambderInvokeCaller reads it
|
|
11
|
+
* back and rebuilds an Error from it as the `cause` of the error it throws,
|
|
12
|
+
* so an error reporter that walks causes sees the callee's stack without
|
|
13
|
+
* being taught anything.
|
|
14
|
+
*
|
|
15
|
+
* Dependency-free and isomorphic: the type is part of the envelope both
|
|
16
|
+
* entries export, and describeCrash needs nothing from Node.
|
|
17
|
+
*/
|
|
18
|
+
export type LambderCrashCause = {
|
|
19
|
+
name: string;
|
|
20
|
+
message: string;
|
|
21
|
+
stack?: string | null;
|
|
22
|
+
};
|
|
23
|
+
export type LambderCrashDetail = LambderCrashCause & {
|
|
24
|
+
/** The Error `cause` chain, outermost first, a few levels deep. */
|
|
25
|
+
causeList?: LambderCrashCause[];
|
|
26
|
+
/** The failed invocation's own awsRequestId, from the render context's lambdaContext. */
|
|
27
|
+
requestId?: string | null;
|
|
28
|
+
/** The function it happened in, from the same place. */
|
|
29
|
+
functionName?: string | null;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Describes whatever was thrown. Pass the render context (the global error
|
|
33
|
+
* handler's second argument) so the detail names the invocation it came
|
|
34
|
+
* from; it is optional because the handler receives null when the context
|
|
35
|
+
* itself could not be built.
|
|
36
|
+
*/
|
|
37
|
+
export declare const describeCrash: (error: unknown, ctx?: {
|
|
38
|
+
lambdaContext?: {
|
|
39
|
+
awsRequestId?: string;
|
|
40
|
+
functionName?: string;
|
|
41
|
+
} | null;
|
|
42
|
+
} | null) => LambderCrashDetail;
|
|
43
|
+
/**
|
|
44
|
+
* Rebuilds an Error (with its cause chain) from a crash detail, so a caller
|
|
45
|
+
* can chain it as the `cause` of its own error and reporters that walk
|
|
46
|
+
* causes see the callee's stack as it was.
|
|
47
|
+
*/
|
|
48
|
+
export declare const errorFromCrashDetail: (crash: LambderCrashDetail) => Error;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A crash, described for a caller that is allowed to see it.
|
|
3
|
+
*
|
|
4
|
+
* A global error handler decides what a failed request learns about the
|
|
5
|
+
* failure. A browser gets a generic message; a trusted caller (another
|
|
6
|
+
* lambda invoking this one, a developer holding a debug cookie) can be
|
|
7
|
+
* handed the whole thing: the error's name, message and stack, its cause
|
|
8
|
+
* chain, and where it happened, so the caller can store it in its own
|
|
9
|
+
* error log and point at the right CloudWatch stream. The envelope carries
|
|
10
|
+
* it in the `crash` field beside errorMessage; LambderInvokeCaller reads it
|
|
11
|
+
* back and rebuilds an Error from it as the `cause` of the error it throws,
|
|
12
|
+
* so an error reporter that walks causes sees the callee's stack without
|
|
13
|
+
* being taught anything.
|
|
14
|
+
*
|
|
15
|
+
* Dependency-free and isomorphic: the type is part of the envelope both
|
|
16
|
+
* entries export, and describeCrash needs nothing from Node.
|
|
17
|
+
*/
|
|
18
|
+
const MAX_NAME_CHARS = 200;
|
|
19
|
+
const MAX_MESSAGE_CHARS = 2000;
|
|
20
|
+
const MAX_STACK_CHARS = 8000;
|
|
21
|
+
const MAX_CAUSE_DEPTH = 3;
|
|
22
|
+
const clamp = (value, max) => (value.length > max ? value.slice(0, max) : value);
|
|
23
|
+
const describeCause = (error) => ({
|
|
24
|
+
name: clamp(error.name || "Error", MAX_NAME_CHARS),
|
|
25
|
+
message: clamp(error.message || String(error), MAX_MESSAGE_CHARS),
|
|
26
|
+
stack: error.stack ? clamp(error.stack, MAX_STACK_CHARS) : null,
|
|
27
|
+
});
|
|
28
|
+
/**
|
|
29
|
+
* Describes whatever was thrown. Pass the render context (the global error
|
|
30
|
+
* handler's second argument) so the detail names the invocation it came
|
|
31
|
+
* from; it is optional because the handler receives null when the context
|
|
32
|
+
* itself could not be built.
|
|
33
|
+
*/
|
|
34
|
+
export const describeCrash = (error, ctx) => {
|
|
35
|
+
const where = {
|
|
36
|
+
requestId: ctx?.lambdaContext?.awsRequestId ?? null,
|
|
37
|
+
functionName: ctx?.lambdaContext?.functionName ?? null,
|
|
38
|
+
};
|
|
39
|
+
if (error instanceof Error) {
|
|
40
|
+
const causeList = [];
|
|
41
|
+
let cause = error.cause;
|
|
42
|
+
let depth = 0;
|
|
43
|
+
while (cause instanceof Error && depth < MAX_CAUSE_DEPTH) {
|
|
44
|
+
causeList.push(describeCause(cause));
|
|
45
|
+
cause = cause.cause;
|
|
46
|
+
depth += 1;
|
|
47
|
+
}
|
|
48
|
+
return { ...describeCause(error), ...(causeList.length ? { causeList } : {}), ...where };
|
|
49
|
+
}
|
|
50
|
+
if (typeof error === "string") {
|
|
51
|
+
return { name: "Error", message: clamp(error, MAX_MESSAGE_CHARS), stack: null, ...where };
|
|
52
|
+
}
|
|
53
|
+
let message;
|
|
54
|
+
try {
|
|
55
|
+
message = JSON.stringify(error) ?? String(error);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
message = String(error);
|
|
59
|
+
}
|
|
60
|
+
return { name: "UnknownError", message: clamp(message, MAX_MESSAGE_CHARS), stack: null, ...where };
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Rebuilds an Error (with its cause chain) from a crash detail, so a caller
|
|
64
|
+
* can chain it as the `cause` of its own error and reporters that walk
|
|
65
|
+
* causes see the callee's stack as it was.
|
|
66
|
+
*/
|
|
67
|
+
export const errorFromCrashDetail = (crash) => {
|
|
68
|
+
const build = (entry, cause) => {
|
|
69
|
+
const error = new Error(entry.message, cause ? { cause } : undefined);
|
|
70
|
+
error.name = entry.name;
|
|
71
|
+
error.stack = entry.stack ?? `${entry.name}: ${entry.message}`;
|
|
72
|
+
return error;
|
|
73
|
+
};
|
|
74
|
+
let cause;
|
|
75
|
+
for (const entry of [...(crash.causeList ?? [])].reverse())
|
|
76
|
+
cause = build(entry, cause);
|
|
77
|
+
return build(crash, cause);
|
|
78
|
+
};
|
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
* When a LambderCaller call's payload clears the configured size, the caller
|
|
5
5
|
* sends the payload's JSON as `payloadGz` (gzip bytes, base64) beside
|
|
6
6
|
* `payloadBytes` (its UTF-8 byte length) in place of `payload`, and the
|
|
7
|
-
* server restores it before anything reads the payload.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* server restores it before anything reads the payload. A Node caller
|
|
8
|
+
* (LambderInvokeCaller) sends `payloadBr` instead, Brotli under the same
|
|
9
|
+
* rules; the server accepts either. Everything else in the envelope
|
|
10
|
+
* (apiName, version, token, siteHost, guardInputs, idempotencyKey) stays
|
|
11
|
+
* plain text, so routing, logging and request mocking are unaffected.
|
|
11
12
|
*
|
|
12
13
|
* Base64 inside the JSON envelope, rather than a binary body with
|
|
13
14
|
* Content-Encoding: API Gateway hands a binary request body to Lambda
|
|
@@ -26,12 +27,23 @@
|
|
|
26
27
|
*/
|
|
27
28
|
import type { LambderCompressionOption } from "./LambderCompressionOption.js";
|
|
28
29
|
/** Envelope field carrying the base64 gzip of the payload's JSON. */
|
|
29
|
-
export declare const
|
|
30
|
+
export declare const COMPRESSED_PAYLOAD_GZ_FIELD = "payloadGz";
|
|
31
|
+
/**
|
|
32
|
+
* Envelope field carrying the base64 Brotli of the payload's JSON: the same
|
|
33
|
+
* pair as payloadGz for a caller that can produce Brotli (a Node caller,
|
|
34
|
+
* LambderInvokeCaller). A request carries one of the two, never both.
|
|
35
|
+
*/
|
|
36
|
+
export declare const COMPRESSED_PAYLOAD_BR_FIELD = "payloadBr";
|
|
30
37
|
/** Envelope field carrying the UTF-8 byte length of that JSON before compression. */
|
|
31
38
|
export declare const COMPRESSED_PAYLOAD_BYTES_FIELD = "payloadBytes";
|
|
32
|
-
/** The pair a
|
|
33
|
-
export type
|
|
34
|
-
[
|
|
39
|
+
/** The pair a gzip-compressing call sends in place of `payload`. */
|
|
40
|
+
export type LambderCompressedGzipPayload = {
|
|
41
|
+
[COMPRESSED_PAYLOAD_GZ_FIELD]: string;
|
|
42
|
+
[COMPRESSED_PAYLOAD_BYTES_FIELD]: number;
|
|
43
|
+
};
|
|
44
|
+
/** The pair a Brotli-compressing call sends in place of `payload`. */
|
|
45
|
+
export type LambderCompressedBrotliPayload = {
|
|
46
|
+
[COMPRESSED_PAYLOAD_BR_FIELD]: string;
|
|
35
47
|
[COMPRESSED_PAYLOAD_BYTES_FIELD]: number;
|
|
36
48
|
};
|
|
37
49
|
/**
|
|
@@ -51,30 +63,41 @@ export type LambderRequestCompressionOption = LambderCompressionOption<LambderRe
|
|
|
51
63
|
*/
|
|
52
64
|
export declare const DEFAULT_REQUEST_COMPRESSION_SETTINGS: LambderRequestCompressionSettings;
|
|
53
65
|
/**
|
|
54
|
-
* Default ceiling for a restored payload
|
|
55
|
-
*
|
|
56
|
-
*
|
|
66
|
+
* Default ceiling for a restored payload, a request's on the server
|
|
67
|
+
* (maxRequestPayloadBytes) or an answer's on the invoke caller
|
|
68
|
+
* (maxResponsePayloadBytes). Lambda's ~6MB invoke cap already bounds the
|
|
69
|
+
* compressed bytes; this bounds what they may expand to, so a highly
|
|
70
|
+
* compressible body cannot exhaust the function's memory.
|
|
57
71
|
*/
|
|
58
|
-
export declare const
|
|
72
|
+
export declare const DEFAULT_MAX_RESTORED_PAYLOAD_BYTES = 20000000;
|
|
73
|
+
/**
|
|
74
|
+
* The two rules every compressed payload follows, whichever algorithm made
|
|
75
|
+
* the bytes: nothing below the threshold is compressed, and the compressed
|
|
76
|
+
* form is only ever sent when its base64 is smaller than the JSON it
|
|
77
|
+
* replaces. The threshold is measured on real UTF-8 bytes, not string
|
|
78
|
+
* length, so a payload of multi-byte text is judged by what actually goes on
|
|
79
|
+
* the wire. `compress` is the algorithm: the browser's CompressionStream for
|
|
80
|
+
* gzip, zlib for Brotli (LambderInvokeCaller); both go through here so the
|
|
81
|
+
* rules cannot drift between them.
|
|
82
|
+
*/
|
|
83
|
+
export declare const compressPayloadWith: <TField extends string>(json: string, minBytes: number, field: TField, compress: (bytes: Uint8Array<ArrayBuffer>) => Promise<Uint8Array>) => Promise<({ [K in TField]: string; } & {
|
|
84
|
+
[COMPRESSED_PAYLOAD_BYTES_FIELD]: number;
|
|
85
|
+
}) | null>;
|
|
59
86
|
/** True when this runtime can compress request payloads (browsers, and Node 18+). */
|
|
60
87
|
export declare const isRequestCompressionAvailable: () => boolean;
|
|
61
88
|
/**
|
|
62
89
|
* Gzip one payload's JSON for sending, or null when the plain JSON should go
|
|
63
|
-
* instead
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* base64 image gzips to nearly its own size, and base64 then inflates the
|
|
69
|
-
* result past the original. Sending that would cost CPU on both ends for a
|
|
70
|
-
* request that got bigger, so the compressed form is only ever sent when it
|
|
71
|
-
* is smaller than the JSON it replaces.
|
|
90
|
+
* instead (see compressPayloadWith for the two rules). The second null
|
|
91
|
+
* matters for the payloads most likely to be large: a base64 image gzips to
|
|
92
|
+
* nearly its own size, and base64 then inflates the result past the
|
|
93
|
+
* original. Sending that would cost CPU on both ends for a request that got
|
|
94
|
+
* bigger, so the compressed form is only ever sent when it is smaller.
|
|
72
95
|
*/
|
|
73
|
-
export declare const
|
|
96
|
+
export declare const compressPayloadGzip: (json: string, minBytes: number) => Promise<LambderCompressedGzipPayload | null>;
|
|
74
97
|
/**
|
|
75
98
|
* Restores a payload the caller compressed, for request mocking
|
|
76
99
|
* (LambderMSW), so a mock handler receives the same payload the server
|
|
77
100
|
* would. The server does NOT use this: it decompresses through zlib, whose
|
|
78
101
|
* bounded output is what makes an untrusted body safe to expand.
|
|
79
102
|
*/
|
|
80
|
-
export declare const
|
|
103
|
+
export declare const decompressPayloadGzip: (payloadGz: string) => Promise<unknown>;
|
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
* When a LambderCaller call's payload clears the configured size, the caller
|
|
5
5
|
* sends the payload's JSON as `payloadGz` (gzip bytes, base64) beside
|
|
6
6
|
* `payloadBytes` (its UTF-8 byte length) in place of `payload`, and the
|
|
7
|
-
* server restores it before anything reads the payload.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* server restores it before anything reads the payload. A Node caller
|
|
8
|
+
* (LambderInvokeCaller) sends `payloadBr` instead, Brotli under the same
|
|
9
|
+
* rules; the server accepts either. Everything else in the envelope
|
|
10
|
+
* (apiName, version, token, siteHost, guardInputs, idempotencyKey) stays
|
|
11
|
+
* plain text, so routing, logging and request mocking are unaffected.
|
|
11
12
|
*
|
|
12
13
|
* Base64 inside the JSON envelope, rather than a binary body with
|
|
13
14
|
* Content-Encoding: API Gateway hands a binary request body to Lambda
|
|
@@ -25,7 +26,13 @@
|
|
|
25
26
|
* body fails instead of expanding without limit.
|
|
26
27
|
*/
|
|
27
28
|
/** Envelope field carrying the base64 gzip of the payload's JSON. */
|
|
28
|
-
export const
|
|
29
|
+
export const COMPRESSED_PAYLOAD_GZ_FIELD = "payloadGz";
|
|
30
|
+
/**
|
|
31
|
+
* Envelope field carrying the base64 Brotli of the payload's JSON: the same
|
|
32
|
+
* pair as payloadGz for a caller that can produce Brotli (a Node caller,
|
|
33
|
+
* LambderInvokeCaller). A request carries one of the two, never both.
|
|
34
|
+
*/
|
|
35
|
+
export const COMPRESSED_PAYLOAD_BR_FIELD = "payloadBr";
|
|
29
36
|
/** Envelope field carrying the UTF-8 byte length of that JSON before compression. */
|
|
30
37
|
export const COMPRESSED_PAYLOAD_BYTES_FIELD = "payloadBytes";
|
|
31
38
|
/**
|
|
@@ -37,13 +44,17 @@ export const COMPRESSED_PAYLOAD_BYTES_FIELD = "payloadBytes";
|
|
|
37
44
|
*/
|
|
38
45
|
export const DEFAULT_REQUEST_COMPRESSION_SETTINGS = { minBytes: 4096 };
|
|
39
46
|
/**
|
|
40
|
-
* Default ceiling for a restored payload
|
|
41
|
-
*
|
|
42
|
-
*
|
|
47
|
+
* Default ceiling for a restored payload, a request's on the server
|
|
48
|
+
* (maxRequestPayloadBytes) or an answer's on the invoke caller
|
|
49
|
+
* (maxResponsePayloadBytes). Lambda's ~6MB invoke cap already bounds the
|
|
50
|
+
* compressed bytes; this bounds what they may expand to, so a highly
|
|
51
|
+
* compressible body cannot exhaust the function's memory.
|
|
43
52
|
*/
|
|
44
|
-
export const
|
|
45
|
-
/**
|
|
53
|
+
export const DEFAULT_MAX_RESTORED_PAYLOAD_BYTES = 20_000_000;
|
|
54
|
+
/** Buffer where there is one (Node); otherwise chunked so a large payload cannot overflow the argument list of String.fromCharCode. */
|
|
46
55
|
const bytesToBase64 = (bytes) => {
|
|
56
|
+
if (typeof Buffer !== "undefined")
|
|
57
|
+
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64");
|
|
47
58
|
const chunkSize = 0x8000;
|
|
48
59
|
let binary = "";
|
|
49
60
|
for (let i = 0; i < bytes.length; i += chunkSize) {
|
|
@@ -51,41 +62,46 @@ const bytesToBase64 = (bytes) => {
|
|
|
51
62
|
}
|
|
52
63
|
return btoa(binary);
|
|
53
64
|
};
|
|
54
|
-
/** True when this runtime can compress request payloads (browsers, and Node 18+). */
|
|
55
|
-
export const isRequestCompressionAvailable = () => typeof CompressionStream !== "undefined" && typeof btoa !== "undefined";
|
|
56
65
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* request that got bigger, so the compressed form is only ever sent when it
|
|
66
|
-
* is smaller than the JSON it replaces.
|
|
66
|
+
* The two rules every compressed payload follows, whichever algorithm made
|
|
67
|
+
* the bytes: nothing below the threshold is compressed, and the compressed
|
|
68
|
+
* form is only ever sent when its base64 is smaller than the JSON it
|
|
69
|
+
* replaces. The threshold is measured on real UTF-8 bytes, not string
|
|
70
|
+
* length, so a payload of multi-byte text is judged by what actually goes on
|
|
71
|
+
* the wire. `compress` is the algorithm: the browser's CompressionStream for
|
|
72
|
+
* gzip, zlib for Brotli (LambderInvokeCaller); both go through here so the
|
|
73
|
+
* rules cannot drift between them.
|
|
67
74
|
*/
|
|
68
|
-
export const
|
|
75
|
+
export const compressPayloadWith = async (json, minBytes, field, compress) => {
|
|
69
76
|
const encoded = new TextEncoder().encode(json);
|
|
70
77
|
if (encoded.length < minBytes)
|
|
71
78
|
return null;
|
|
72
|
-
const
|
|
73
|
-
const compressed = new Uint8Array(await new Response(stream).arrayBuffer());
|
|
74
|
-
const base64 = bytesToBase64(compressed);
|
|
79
|
+
const base64 = bytesToBase64(await compress(encoded));
|
|
75
80
|
if (base64.length >= encoded.length)
|
|
76
81
|
return null;
|
|
77
|
-
return {
|
|
78
|
-
[COMPRESSED_PAYLOAD_FIELD]: base64,
|
|
79
|
-
[COMPRESSED_PAYLOAD_BYTES_FIELD]: encoded.length,
|
|
80
|
-
};
|
|
82
|
+
return { [field]: base64, [COMPRESSED_PAYLOAD_BYTES_FIELD]: encoded.length };
|
|
81
83
|
};
|
|
84
|
+
/** True when this runtime can compress request payloads (browsers, and Node 18+). */
|
|
85
|
+
export const isRequestCompressionAvailable = () => typeof CompressionStream !== "undefined" && typeof btoa !== "undefined";
|
|
86
|
+
/**
|
|
87
|
+
* Gzip one payload's JSON for sending, or null when the plain JSON should go
|
|
88
|
+
* instead (see compressPayloadWith for the two rules). The second null
|
|
89
|
+
* matters for the payloads most likely to be large: a base64 image gzips to
|
|
90
|
+
* nearly its own size, and base64 then inflates the result past the
|
|
91
|
+
* original. Sending that would cost CPU on both ends for a request that got
|
|
92
|
+
* bigger, so the compressed form is only ever sent when it is smaller.
|
|
93
|
+
*/
|
|
94
|
+
export const compressPayloadGzip = (json, minBytes) => compressPayloadWith(json, minBytes, COMPRESSED_PAYLOAD_GZ_FIELD, async (bytes) => {
|
|
95
|
+
const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream("gzip"));
|
|
96
|
+
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
97
|
+
});
|
|
82
98
|
/**
|
|
83
99
|
* Restores a payload the caller compressed, for request mocking
|
|
84
100
|
* (LambderMSW), so a mock handler receives the same payload the server
|
|
85
101
|
* would. The server does NOT use this: it decompresses through zlib, whose
|
|
86
102
|
* bounded output is what makes an untrusted body safe to expand.
|
|
87
103
|
*/
|
|
88
|
-
export const
|
|
104
|
+
export const decompressPayloadGzip = async (payloadGz) => {
|
|
89
105
|
const binary = atob(payloadGz);
|
|
90
106
|
const bytes = new Uint8Array(binary.length);
|
|
91
107
|
for (let i = 0; i < binary.length; i += 1) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
1
|
+
import type { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
2
2
|
import { type LambderCompressionOption } from "../shared/LambderCompressionOption.js";
|
|
3
3
|
export interface LambderDdbCacheOptions {
|
|
4
4
|
tableName: string;
|
|
@@ -72,7 +72,10 @@ export declare class LambderDdbCache {
|
|
|
72
72
|
readonly tableName: string;
|
|
73
73
|
readonly keyPrefix: string;
|
|
74
74
|
readonly namespace: string;
|
|
75
|
-
|
|
75
|
+
/** The client given at creation, or one created from `region` on first use; the SDK arrives with it. */
|
|
76
|
+
private readonly providedClient;
|
|
77
|
+
private readonly region;
|
|
78
|
+
private readyPromise;
|
|
76
79
|
private readonly defaultTtlSeconds;
|
|
77
80
|
private readonly chunkBytes;
|
|
78
81
|
private readonly compression;
|
|
@@ -80,6 +83,8 @@ export declare class LambderDdbCache {
|
|
|
80
83
|
private readonly memory;
|
|
81
84
|
private readonly inFlight;
|
|
82
85
|
constructor(options: LambderDdbCacheOptions);
|
|
86
|
+
/** The SDK and the client, loaded and created the first time the table is touched (see LambderDdbSdk). */
|
|
87
|
+
private ready;
|
|
83
88
|
get<T>(key: LambderCacheKey): Promise<T | undefined>;
|
|
84
89
|
private getByAddress;
|
|
85
90
|
has(key: LambderCacheKey): Promise<boolean>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { loadDynamoClientSdk } from "./LambderDdbSdk.js";
|
|
2
2
|
import { getCrypto } from "../shared/node-polyfills.js";
|
|
3
|
-
import { compressText,
|
|
3
|
+
import { compressText, restoreText } from "../shared/LambderCompressionCodec.js";
|
|
4
4
|
import { resolveCompressionOption, } from "../shared/LambderCompressionOption.js";
|
|
5
5
|
import { LRUCache } from "lru-cache";
|
|
6
6
|
const DEFAULT_TTL_SECONDS = 365 * 24 * 60 * 60;
|
|
@@ -84,7 +84,10 @@ export class LambderDdbCache {
|
|
|
84
84
|
tableName;
|
|
85
85
|
keyPrefix;
|
|
86
86
|
namespace;
|
|
87
|
-
client;
|
|
87
|
+
/** The client given at creation, or one created from `region` on first use; the SDK arrives with it. */
|
|
88
|
+
providedClient;
|
|
89
|
+
region;
|
|
90
|
+
readyPromise;
|
|
88
91
|
defaultTtlSeconds;
|
|
89
92
|
chunkBytes;
|
|
90
93
|
compression;
|
|
@@ -114,7 +117,15 @@ export class LambderDdbCache {
|
|
|
114
117
|
maxSize: positiveInteger(memoryMaxBytes, "memoryMaxBytes"),
|
|
115
118
|
sizeCalculation: (entry) => entry.stored.length,
|
|
116
119
|
});
|
|
117
|
-
this.
|
|
120
|
+
this.providedClient = options.client;
|
|
121
|
+
this.region = options.region ?? "us-east-1";
|
|
122
|
+
}
|
|
123
|
+
/** The SDK and the client, loaded and created the first time the table is touched (see LambderDdbSdk). */
|
|
124
|
+
ready() {
|
|
125
|
+
this.readyPromise ??= loadDynamoClientSdk("LambderDdbCache")
|
|
126
|
+
.then((sdk) => ({ sdk, client: this.providedClient ?? new sdk.DynamoDBClient({ region: this.region }) }))
|
|
127
|
+
.catch((error) => { this.readyPromise = undefined; throw error; });
|
|
128
|
+
return this.readyPromise;
|
|
118
129
|
}
|
|
119
130
|
async get(key) {
|
|
120
131
|
return await this.getByAddress(this.normalizeKey(key));
|
|
@@ -205,7 +216,8 @@ export class LambderDdbCache {
|
|
|
205
216
|
},
|
|
206
217
|
}));
|
|
207
218
|
await this.batchWrite(writes);
|
|
208
|
-
await this.
|
|
219
|
+
const { client, sdk } = await this.ready();
|
|
220
|
+
await client.send(new sdk.PutItemCommand({
|
|
209
221
|
TableName: this.tableName,
|
|
210
222
|
Item: {
|
|
211
223
|
pk: { S: pk },
|
|
@@ -356,7 +368,8 @@ export class LambderDdbCache {
|
|
|
356
368
|
async acquireLease(pk, address, owner, leaseSeconds) {
|
|
357
369
|
const now = this.nowSeconds();
|
|
358
370
|
try {
|
|
359
|
-
await this.
|
|
371
|
+
const { client, sdk } = await this.ready();
|
|
372
|
+
await client.send(new sdk.PutItemCommand({
|
|
360
373
|
TableName: this.tableName,
|
|
361
374
|
Item: {
|
|
362
375
|
pk: { S: pk },
|
|
@@ -378,7 +391,8 @@ export class LambderDdbCache {
|
|
|
378
391
|
}
|
|
379
392
|
async releaseLease(pk, address, owner) {
|
|
380
393
|
try {
|
|
381
|
-
await this.
|
|
394
|
+
const { client, sdk } = await this.ready();
|
|
395
|
+
await client.send(new sdk.DeleteItemCommand({
|
|
382
396
|
TableName: this.tableName,
|
|
383
397
|
Key: { pk: { S: pk }, sk: { S: this.itemSortKey(address, LOCK_SORT_KEY) } },
|
|
384
398
|
ConditionExpression: "#owner = :owner",
|
|
@@ -393,7 +407,8 @@ export class LambderDdbCache {
|
|
|
393
407
|
}
|
|
394
408
|
}
|
|
395
409
|
async readManifest(pk, address) {
|
|
396
|
-
const
|
|
410
|
+
const { client, sdk } = await this.ready();
|
|
411
|
+
const response = await client.send(new sdk.GetItemCommand({
|
|
397
412
|
TableName: this.tableName,
|
|
398
413
|
Key: { pk: { S: pk }, sk: { S: this.itemSortKey(address, META_SORT_KEY) } },
|
|
399
414
|
ConsistentRead: false,
|
|
@@ -469,7 +484,8 @@ export class LambderDdbCache {
|
|
|
469
484
|
const items = [];
|
|
470
485
|
let cursor;
|
|
471
486
|
do {
|
|
472
|
-
const
|
|
487
|
+
const { client, sdk } = await this.ready();
|
|
488
|
+
const response = await client.send(new sdk.QueryCommand({
|
|
473
489
|
TableName: this.tableName,
|
|
474
490
|
KeyConditionExpression: options.prefix
|
|
475
491
|
? "#pk = :pk AND begins_with(#sk, :prefix)"
|
|
@@ -494,7 +510,8 @@ export class LambderDdbCache {
|
|
|
494
510
|
}
|
|
495
511
|
async invalidateManifest(pk, address, version) {
|
|
496
512
|
try {
|
|
497
|
-
await this.
|
|
513
|
+
const { client, sdk } = await this.ready();
|
|
514
|
+
await client.send(new sdk.DeleteItemCommand({
|
|
498
515
|
TableName: this.tableName,
|
|
499
516
|
Key: { pk: { S: pk }, sk: { S: this.itemSortKey(address, META_SORT_KEY) } },
|
|
500
517
|
ConditionExpression: "#version = :version",
|
|
@@ -515,7 +532,8 @@ export class LambderDdbCache {
|
|
|
515
532
|
if (attempt >= MAX_BATCH_RETRIES) {
|
|
516
533
|
throw new Error(`DynamoDB cache batch write remained throttled after ${MAX_BATCH_RETRIES} attempts`);
|
|
517
534
|
}
|
|
518
|
-
const
|
|
535
|
+
const { client, sdk } = await this.ready();
|
|
536
|
+
const response = await client.send(new sdk.BatchWriteItemCommand({ RequestItems: { [this.tableName]: pending } }));
|
|
519
537
|
pending = response.UnprocessedItems?.[this.tableName] ?? [];
|
|
520
538
|
if (pending.length > 0) {
|
|
521
539
|
const backoff = Math.min(25 * 2 ** attempt, 1_000) + Math.floor(Math.random() * 50);
|
|
@@ -526,7 +544,7 @@ export class LambderDdbCache {
|
|
|
526
544
|
}
|
|
527
545
|
/** The JSON text of a stored payload. */
|
|
528
546
|
async decode(stored, encoding, uncompressedBytes) {
|
|
529
|
-
return encoding === "br" ? await
|
|
547
|
+
return encoding === "br" ? await restoreText(stored, "br", { declaredBytes: uncompressedBytes }) : stored.toString("utf8");
|
|
530
548
|
}
|
|
531
549
|
remember(key, stored, encoding, uncompressedBytes, expiresAt) {
|
|
532
550
|
if (!this.memory)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
1
|
+
import type { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
2
2
|
import { type LambderCompressionOption } from "../shared/LambderCompressionOption.js";
|
|
3
3
|
export interface LambderDdbIdempotencyOptions {
|
|
4
4
|
tableName: string;
|
|
@@ -56,8 +56,13 @@ export declare class LambderDdbIdempotency {
|
|
|
56
56
|
readonly tableName: string;
|
|
57
57
|
readonly keyPrefix: string;
|
|
58
58
|
private readonly compression;
|
|
59
|
-
|
|
59
|
+
/** The client given at creation, or one created from `region` on first use; the SDK arrives with it. */
|
|
60
|
+
private readonly providedClient;
|
|
61
|
+
private readonly region;
|
|
62
|
+
private readyPromise;
|
|
60
63
|
constructor(options: LambderDdbIdempotencyOptions);
|
|
64
|
+
/** The SDK and the client, loaded and created the first time the table is touched (see LambderDdbSdk). */
|
|
65
|
+
private ready;
|
|
61
66
|
private itemKey;
|
|
62
67
|
/** Parse a stored item's response headers. */
|
|
63
68
|
private static readItemHeaders;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import crypto from "crypto";
|
|
2
|
-
import {
|
|
3
|
-
import { compressText,
|
|
2
|
+
import { loadDynamoClientSdk } from "./LambderDdbSdk.js";
|
|
3
|
+
import { compressText, restoreText } from "../shared/LambderCompressionCodec.js";
|
|
4
4
|
import { resolveCompressionOption, } from "../shared/LambderCompressionOption.js";
|
|
5
5
|
/** Bodies of 1KB or more are stored Brotli-compressed by default; smaller ones stay plain. */
|
|
6
6
|
const COMPRESSION_DEFAULTS = { minBytes: 1024, quality: 5 };
|
|
@@ -38,14 +38,25 @@ export class LambderDdbIdempotency {
|
|
|
38
38
|
tableName;
|
|
39
39
|
keyPrefix;
|
|
40
40
|
compression;
|
|
41
|
-
client;
|
|
41
|
+
/** The client given at creation, or one created from `region` on first use; the SDK arrives with it. */
|
|
42
|
+
providedClient;
|
|
43
|
+
region;
|
|
44
|
+
readyPromise;
|
|
42
45
|
constructor(options) {
|
|
43
46
|
if (!options.tableName.trim())
|
|
44
47
|
throw new Error("tableName is required");
|
|
45
48
|
this.tableName = options.tableName;
|
|
46
49
|
this.keyPrefix = options.keyPrefix ?? "IDEM";
|
|
47
50
|
this.compression = resolveCompressionOption(options.compression, COMPRESSION_DEFAULTS);
|
|
48
|
-
this.
|
|
51
|
+
this.providedClient = options.client;
|
|
52
|
+
this.region = options.region;
|
|
53
|
+
}
|
|
54
|
+
/** The SDK and the client, loaded and created the first time the table is touched (see LambderDdbSdk). */
|
|
55
|
+
ready() {
|
|
56
|
+
this.readyPromise ??= loadDynamoClientSdk("LambderDdbIdempotency")
|
|
57
|
+
.then((sdk) => ({ sdk, client: this.providedClient ?? new sdk.DynamoDBClient(this.region ? { region: this.region } : {}) }))
|
|
58
|
+
.catch((error) => { this.readyPromise = undefined; throw error; });
|
|
59
|
+
return this.readyPromise;
|
|
49
60
|
}
|
|
50
61
|
itemKey(scopeKey) {
|
|
51
62
|
return { pk: { S: `${this.keyPrefix}#${scopeKey}` }, sk: { S: "idem" } };
|
|
@@ -67,7 +78,7 @@ export class LambderDdbIdempotency {
|
|
|
67
78
|
static async readItemBody(item) {
|
|
68
79
|
const compressed = item.bodyBr?.B;
|
|
69
80
|
if (compressed)
|
|
70
|
-
return await
|
|
81
|
+
return await restoreText(compressed, "br", { declaredBytes: Number(item.bodyBytes?.N ?? 0) });
|
|
71
82
|
return item.body?.S ?? "";
|
|
72
83
|
}
|
|
73
84
|
/**
|
|
@@ -77,7 +88,8 @@ export class LambderDdbIdempotency {
|
|
|
77
88
|
* proceeds to begin(), whose read is authoritative.
|
|
78
89
|
*/
|
|
79
90
|
async peek(scopeKey) {
|
|
80
|
-
const
|
|
91
|
+
const { client, sdk } = await this.ready();
|
|
92
|
+
const existing = await client.send(new sdk.GetItemCommand({
|
|
81
93
|
TableName: this.tableName,
|
|
82
94
|
Key: this.itemKey(scopeKey),
|
|
83
95
|
}));
|
|
@@ -103,7 +115,8 @@ export class LambderDdbIdempotency {
|
|
|
103
115
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
104
116
|
const ownerToken = crypto.randomBytes(16).toString("hex");
|
|
105
117
|
try {
|
|
106
|
-
await this.
|
|
118
|
+
const { client, sdk } = await this.ready();
|
|
119
|
+
await client.send(new sdk.PutItemCommand({
|
|
107
120
|
TableName: this.tableName,
|
|
108
121
|
Item: {
|
|
109
122
|
...this.itemKey(scopeKey),
|
|
@@ -120,7 +133,8 @@ export class LambderDdbIdempotency {
|
|
|
120
133
|
if (error.name !== "ConditionalCheckFailedException")
|
|
121
134
|
throw error;
|
|
122
135
|
}
|
|
123
|
-
const
|
|
136
|
+
const { client, sdk } = await this.ready();
|
|
137
|
+
const existing = await client.send(new sdk.GetItemCommand({
|
|
124
138
|
TableName: this.tableName,
|
|
125
139
|
Key: this.itemKey(scopeKey),
|
|
126
140
|
ConsistentRead: true,
|
|
@@ -168,7 +182,8 @@ export class LambderDdbIdempotency {
|
|
|
168
182
|
bodyAttributes = { body: { S: body } };
|
|
169
183
|
}
|
|
170
184
|
try {
|
|
171
|
-
await this.
|
|
185
|
+
const { client, sdk } = await this.ready();
|
|
186
|
+
await client.send(new sdk.PutItemCommand({
|
|
172
187
|
TableName: this.tableName,
|
|
173
188
|
Item: {
|
|
174
189
|
...this.itemKey(scopeKey),
|
|
@@ -197,7 +212,8 @@ export class LambderDdbIdempotency {
|
|
|
197
212
|
*/
|
|
198
213
|
async abandon(scopeKey, ownerToken) {
|
|
199
214
|
try {
|
|
200
|
-
await this.
|
|
215
|
+
const { client, sdk } = await this.ready();
|
|
216
|
+
await client.send(new sdk.DeleteItemCommand({
|
|
201
217
|
TableName: this.tableName,
|
|
202
218
|
Key: this.itemKey(scopeKey),
|
|
203
219
|
ConditionExpression: "ownerToken = :owner",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
1
|
+
import type { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
2
2
|
/**
|
|
3
3
|
* The fixed windows a policy may cap, smallest first (the evaluation order),
|
|
4
4
|
* with their length. The policy type derives from this table, so the two can
|
|
@@ -68,10 +68,15 @@ export interface LambderDdbRateLimiterOptions {
|
|
|
68
68
|
export declare class LambderDdbRateLimiter {
|
|
69
69
|
readonly tableName: string;
|
|
70
70
|
readonly keyPrefix: string;
|
|
71
|
-
|
|
71
|
+
/** The client given at creation, or one created from `region` on first use; the SDK arrives with it. */
|
|
72
|
+
private readonly providedClient;
|
|
73
|
+
private readonly region;
|
|
74
|
+
private readyPromise;
|
|
72
75
|
private readonly ttlWindowMultiplier;
|
|
73
76
|
private readonly failOpen;
|
|
74
77
|
constructor(options: LambderDdbRateLimiterOptions);
|
|
78
|
+
/** The SDK and the client, loaded and created the first time the table is touched (see LambderDdbSdk). */
|
|
79
|
+
private ready;
|
|
75
80
|
/**
|
|
76
81
|
* Increment every configured window for `trackerKey` (IP, session, user id, ...)
|
|
77
82
|
* and report whether any of them is over its limit, with the window's
|