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.
Files changed (42) 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.d.ts +2 -2
  8. package/dist/core/Lambder.js +10 -3
  9. package/dist/core/LambderContext.d.ts +6 -5
  10. package/dist/core/LambderContext.js +20 -12
  11. package/dist/core/LambderResolver.d.ts +7 -5
  12. package/dist/core/LambderResolver.js +7 -3
  13. package/dist/core/LambderResponseBuilder.d.ts +17 -3
  14. package/dist/core/LambderResponseBuilder.js +2 -2
  15. package/dist/index.d.ts +10 -6
  16. package/dist/index.js +6 -2
  17. package/dist/invoke/LambderInvokeCaller.d.ts +322 -0
  18. package/dist/invoke/LambderInvokeCaller.js +654 -0
  19. package/dist/policies/LambderApiGuards.d.ts +8 -8
  20. package/dist/policies/LambderApiRateLimits.d.ts +3 -3
  21. package/dist/session/LambderSessionManager.d.ts +5 -1
  22. package/dist/session/LambderSessionManager.js +26 -13
  23. package/dist/shared/LambderApiContract.d.ts +9 -0
  24. package/dist/shared/LambderApiOutcome.d.ts +69 -0
  25. package/dist/shared/LambderApiOutcome.js +79 -0
  26. package/dist/shared/LambderCallOptions.d.ts +71 -0
  27. package/dist/shared/LambderCallOptions.js +16 -0
  28. package/dist/shared/LambderCompressionCodec.d.ts +45 -8
  29. package/dist/shared/LambderCompressionCodec.js +44 -14
  30. package/dist/shared/LambderCrashDetail.d.ts +48 -0
  31. package/dist/shared/LambderCrashDetail.js +78 -0
  32. package/dist/shared/LambderRequestPayload.d.ts +46 -23
  33. package/dist/shared/LambderRequestPayload.js +47 -31
  34. package/dist/stores/LambderDdbCache.d.ts +7 -2
  35. package/dist/stores/LambderDdbCache.js +30 -12
  36. package/dist/stores/LambderDdbIdempotency.d.ts +7 -2
  37. package/dist/stores/LambderDdbIdempotency.js +26 -10
  38. package/dist/stores/LambderDdbRateLimiter.d.ts +7 -2
  39. package/dist/stores/LambderDdbRateLimiter.js +16 -4
  40. package/dist/stores/LambderDdbSdk.d.ts +20 -0
  41. package/dist/stores/LambderDdbSdk.js +31 -0
  42. 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. Everything else in
8
- * the envelope (apiName, version, token, siteHost, guardInputs,
9
- * idempotencyKey) stays plain text, so routing, logging and request mocking
10
- * are unaffected.
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 COMPRESSED_PAYLOAD_FIELD = "payloadGz";
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 compressed call sends in place of `payload`. */
33
- export type LambderCompressedPayload = {
34
- [COMPRESSED_PAYLOAD_FIELD]: string;
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. Lambda's ~6MB invoke cap already
55
- * bounds the compressed bytes; this bounds what they may expand to, so a
56
- * highly compressible body cannot exhaust the function's memory.
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 DEFAULT_MAX_REQUEST_PAYLOAD_BYTES = 20000000;
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: below the threshold, or when compressing did not make it smaller.
64
- * The threshold is measured on real UTF-8 bytes, not string length, so a
65
- * payload of multi-byte text is judged by what actually goes on the wire.
66
- *
67
- * The second null matters for the payloads most likely to be large: a
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 compressPayloadJson: (json: string, minBytes: number) => Promise<LambderCompressedPayload | null>;
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 decompressPayloadJson: (payloadGz: string) => Promise<unknown>;
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. Everything else in
8
- * the envelope (apiName, version, token, siteHost, guardInputs,
9
- * idempotencyKey) stays plain text, so routing, logging and request mocking
10
- * are unaffected.
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 COMPRESSED_PAYLOAD_FIELD = "payloadGz";
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. Lambda's ~6MB invoke cap already
41
- * bounds the compressed bytes; this bounds what they may expand to, so a
42
- * highly compressible body cannot exhaust the function's memory.
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 DEFAULT_MAX_REQUEST_PAYLOAD_BYTES = 20_000_000;
45
- /** Chunked so a large payload cannot overflow the argument list of String.fromCharCode. */
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
- * Gzip one payload's JSON for sending, or null when the plain JSON should go
58
- * instead: below the threshold, or when compressing did not make it smaller.
59
- * The threshold is measured on real UTF-8 bytes, not string length, so a
60
- * payload of multi-byte text is judged by what actually goes on the wire.
61
- *
62
- * The second null matters for the payloads most likely to be large: a
63
- * base64 image gzips to nearly its own size, and base64 then inflates the
64
- * result past the original. Sending that would cost CPU on both ends for a
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 compressPayloadJson = async (json, minBytes) => {
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 stream = new Blob([encoded]).stream().pipeThrough(new CompressionStream("gzip"));
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 decompressPayloadJson = async (payloadGz) => {
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
- private readonly client;
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 { BatchWriteItemCommand, DeleteItemCommand, DynamoDBClient, GetItemCommand, PutItemCommand, QueryCommand, } from "@aws-sdk/client-dynamodb";
1
+ import { loadDynamoClientSdk } from "./LambderDdbSdk.js";
2
2
  import { getCrypto } from "../shared/node-polyfills.js";
3
- import { compressText, restoreBoundedText } from "../shared/LambderCompressionCodec.js";
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.client = options.client ?? new DynamoDBClient({ region: options.region ?? "us-east-1" });
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.client.send(new PutItemCommand({
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.client.send(new PutItemCommand({
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.client.send(new DeleteItemCommand({
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 response = await this.client.send(new GetItemCommand({
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 response = await this.client.send(new QueryCommand({
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.client.send(new DeleteItemCommand({
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 response = await this.client.send(new BatchWriteItemCommand({ RequestItems: { [this.tableName]: pending } }));
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 restoreBoundedText(stored, uncompressedBytes, "br") : stored.toString("utf8");
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
- private readonly client;
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 { DynamoDBClient, PutItemCommand, GetItemCommand, DeleteItemCommand, } from "@aws-sdk/client-dynamodb";
3
- import { compressText, restoreBoundedText } from "../shared/LambderCompressionCodec.js";
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.client = options.client ?? new DynamoDBClient(options.region ? { region: options.region } : {});
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 restoreBoundedText(compressed, Number(item.bodyBytes?.N ?? 0), "br");
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 existing = await this.client.send(new GetItemCommand({
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.client.send(new PutItemCommand({
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 existing = await this.client.send(new GetItemCommand({
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.client.send(new PutItemCommand({
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.client.send(new DeleteItemCommand({
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
- private readonly client;
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