lambder 5.1.3 → 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.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 +6 -1
|
@@ -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
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { loadDynamoClientSdk } from "./LambderDdbSdk.js";
|
|
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
|
|
@@ -32,7 +32,10 @@ export const RATE_LIMIT_WINDOWS = [
|
|
|
32
32
|
export class LambderDdbRateLimiter {
|
|
33
33
|
tableName;
|
|
34
34
|
keyPrefix;
|
|
35
|
-
client;
|
|
35
|
+
/** The client given at creation, or one created from `region` on first use; the SDK arrives with it. */
|
|
36
|
+
providedClient;
|
|
37
|
+
region;
|
|
38
|
+
readyPromise;
|
|
36
39
|
ttlWindowMultiplier;
|
|
37
40
|
failOpen;
|
|
38
41
|
constructor(options) {
|
|
@@ -45,7 +48,15 @@ export class LambderDdbRateLimiter {
|
|
|
45
48
|
throw new Error("ttlWindowMultiplier must be a number greater than or equal to 1");
|
|
46
49
|
}
|
|
47
50
|
this.failOpen = options.failOpen ?? false;
|
|
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("LambderDdbRateLimiter")
|
|
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
|
/**
|
|
51
62
|
* Increment every configured window for `trackerKey` (IP, session, user id, ...)
|
|
@@ -84,7 +95,8 @@ export class LambderDdbRateLimiter {
|
|
|
84
95
|
},
|
|
85
96
|
};
|
|
86
97
|
try {
|
|
87
|
-
await this.
|
|
98
|
+
const { client, sdk } = await this.ready();
|
|
99
|
+
await client.send(new sdk.UpdateItemCommand(input));
|
|
88
100
|
return false;
|
|
89
101
|
}
|
|
90
102
|
catch (error) {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The DynamoDB SDK, loaded on first use.
|
|
3
|
+
*
|
|
4
|
+
* `@aws-sdk/client-dynamodb` and `@aws-sdk/lib-dynamodb` are optional peer
|
|
5
|
+
* dependencies, and an app that keeps no sessions and uses none of the
|
|
6
|
+
* DynamoDB stores should neither install them nor pay for loading them: a
|
|
7
|
+
* module-level import costs every cold start something and, bundled, makes
|
|
8
|
+
* every Lambder app reference both packages. So the session manager and the
|
|
9
|
+
* three stores import their types only and take the classes from here the
|
|
10
|
+
* first time they touch the table, the way LambderS3FileSource and
|
|
11
|
+
* LambderInvokeCaller load theirs. One loader per package, memoized for the
|
|
12
|
+
* container's life; a missing package fails that first call with the
|
|
13
|
+
* install hint rather than failing the import of lambder itself.
|
|
14
|
+
*/
|
|
15
|
+
export type LambderDynamoClientSdk = typeof import("@aws-sdk/client-dynamodb");
|
|
16
|
+
export type LambderDynamoDocumentSdk = typeof import("@aws-sdk/lib-dynamodb");
|
|
17
|
+
/** `@aws-sdk/client-dynamodb`, for the item-level API the stores speak and the client the session manager wraps. */
|
|
18
|
+
export declare const loadDynamoClientSdk: (user: string) => Promise<LambderDynamoClientSdk>;
|
|
19
|
+
/** `@aws-sdk/lib-dynamodb`, the document client the session manager reads and writes through. */
|
|
20
|
+
export declare const loadDynamoDocumentSdk: (user: string) => Promise<LambderDynamoDocumentSdk>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The DynamoDB SDK, loaded on first use.
|
|
3
|
+
*
|
|
4
|
+
* `@aws-sdk/client-dynamodb` and `@aws-sdk/lib-dynamodb` are optional peer
|
|
5
|
+
* dependencies, and an app that keeps no sessions and uses none of the
|
|
6
|
+
* DynamoDB stores should neither install them nor pay for loading them: a
|
|
7
|
+
* module-level import costs every cold start something and, bundled, makes
|
|
8
|
+
* every Lambder app reference both packages. So the session manager and the
|
|
9
|
+
* three stores import their types only and take the classes from here the
|
|
10
|
+
* first time they touch the table, the way LambderS3FileSource and
|
|
11
|
+
* LambderInvokeCaller load theirs. One loader per package, memoized for the
|
|
12
|
+
* container's life; a missing package fails that first call with the
|
|
13
|
+
* install hint rather than failing the import of lambder itself.
|
|
14
|
+
*/
|
|
15
|
+
let clientSdk;
|
|
16
|
+
let documentSdk;
|
|
17
|
+
const withInstallHint = (loading, packageName, user, reset) => loading.catch((cause) => {
|
|
18
|
+
// Not memoized: the package may be installed later in the same process (tests), and the next caller names itself.
|
|
19
|
+
reset();
|
|
20
|
+
throw new Error(`${user} requires ${packageName}: npm install ${packageName}`, { cause });
|
|
21
|
+
});
|
|
22
|
+
/** `@aws-sdk/client-dynamodb`, for the item-level API the stores speak and the client the session manager wraps. */
|
|
23
|
+
export const loadDynamoClientSdk = (user) => {
|
|
24
|
+
clientSdk ??= withInstallHint(import("@aws-sdk/client-dynamodb"), "@aws-sdk/client-dynamodb", user, () => { clientSdk = undefined; });
|
|
25
|
+
return clientSdk;
|
|
26
|
+
};
|
|
27
|
+
/** `@aws-sdk/lib-dynamodb`, the document client the session manager reads and writes through. */
|
|
28
|
+
export const loadDynamoDocumentSdk = (user) => {
|
|
29
|
+
documentSdk ??= withInstallHint(import("@aws-sdk/lib-dynamodb"), "@aws-sdk/lib-dynamodb", user, () => { documentSdk = undefined; });
|
|
30
|
+
return documentSdk;
|
|
31
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lambder",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "6.0.1",
|
|
4
4
|
"description": "Opinionated serverless web framework for TypeScript on AWS Lambda: type-safe APIs from Zod schemas, DynamoDB sessions, and declarative rate limits, authorization guards and idempotency.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"lambda",
|
|
@@ -87,6 +87,7 @@
|
|
|
87
87
|
},
|
|
88
88
|
"peerDependencies": {
|
|
89
89
|
"@aws-sdk/client-dynamodb": "^3.574.0",
|
|
90
|
+
"@aws-sdk/client-lambda": "^3.574.0",
|
|
90
91
|
"@aws-sdk/client-s3": "^3.574.0",
|
|
91
92
|
"@aws-sdk/lib-dynamodb": "^3.574.0",
|
|
92
93
|
"msw": "^2.0.0",
|
|
@@ -96,6 +97,9 @@
|
|
|
96
97
|
"@aws-sdk/client-dynamodb": {
|
|
97
98
|
"optional": true
|
|
98
99
|
},
|
|
100
|
+
"@aws-sdk/client-lambda": {
|
|
101
|
+
"optional": true
|
|
102
|
+
},
|
|
99
103
|
"@aws-sdk/client-s3": {
|
|
100
104
|
"optional": true
|
|
101
105
|
},
|
|
@@ -111,6 +115,7 @@
|
|
|
111
115
|
},
|
|
112
116
|
"devDependencies": {
|
|
113
117
|
"@aws-sdk/client-dynamodb": "^3.913.0",
|
|
118
|
+
"@aws-sdk/client-lambda": "^3.1131.0",
|
|
114
119
|
"@aws-sdk/client-s3": "^3.1127.0",
|
|
115
120
|
"@aws-sdk/lib-dynamodb": "^3.913.0",
|
|
116
121
|
"@types/aws-lambda": "^8.10.136",
|