lambder 4.6.1 → 4.7.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 +88 -3
- package/dist/client/LambderCaller.d.ts +19 -0
- package/dist/client/LambderCaller.js +19 -2
- package/dist/client/LambderMSW.js +15 -0
- package/dist/client.d.ts +4 -0
- package/dist/client.js +4 -0
- package/dist/core/Lambder.d.ts +50 -14
- package/dist/core/Lambder.js +36 -6
- package/dist/core/LambderContext.d.ts +20 -0
- package/dist/core/LambderContext.js +54 -0
- package/dist/core/LambderResponse.d.ts +21 -3
- package/dist/core/LambderResponse.js +26 -9
- package/dist/index.d.ts +7 -2
- package/dist/index.js +7 -0
- package/dist/session/LambderSessionManager.d.ts +1 -1
- package/dist/session/LambderSessionManager.js +4 -3
- package/dist/shared/LambderApiError.d.ts +2 -0
- package/dist/shared/LambderApiError.js +2 -0
- package/dist/shared/LambderCompressionCodec.d.ts +55 -0
- package/dist/shared/LambderCompressionCodec.js +113 -0
- package/dist/shared/LambderCompressionOption.d.ts +51 -0
- package/dist/shared/LambderCompressionOption.js +52 -0
- package/dist/shared/LambderRequestPayload.d.ts +80 -0
- package/dist/shared/LambderRequestPayload.js +96 -0
- package/dist/stores/LambderDdbCache.d.ts +1 -1
- package/dist/stores/LambderDdbCache.js +5 -4
- package/dist/stores/LambderDdbIdempotency.d.ts +1 -1
- package/dist/stores/LambderDdbIdempotency.js +4 -3
- package/package.json +22 -7
- package/dist/stores/LambderDdbCompression.d.ts +0 -25
- package/dist/stores/LambderDdbCompression.js +0 -61
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request payload compression: the wire format both sides speak.
|
|
3
|
+
*
|
|
4
|
+
* When a LambderCaller call's payload clears the configured size, the caller
|
|
5
|
+
* sends the payload's JSON as `payloadGz` (gzip bytes, base64) beside
|
|
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.
|
|
11
|
+
*
|
|
12
|
+
* Base64 inside the JSON envelope, rather than a binary body with
|
|
13
|
+
* Content-Encoding: API Gateway hands a binary request body to Lambda
|
|
14
|
+
* base64-encoded anyway, so binary saves nothing against Lambda's ~6MB
|
|
15
|
+
* invoke payload cap while adding a content-type negotiation that gateways,
|
|
16
|
+
* CDNs and mock servers each treat differently. Base64's 4/3 overhead
|
|
17
|
+
* applies to bytes that already shrank several times over.
|
|
18
|
+
*
|
|
19
|
+
* gzip rather than Brotli because the browser's CompressionStream offers
|
|
20
|
+
* gzip and deflate only; responses, compressed by Node, do prefer Brotli.
|
|
21
|
+
*
|
|
22
|
+
* `payloadBytes` is not bookkeeping: it bounds the server's decompression
|
|
23
|
+
* and the restored length must match it exactly, the same guarantee
|
|
24
|
+
* LambderCompressionCodec gives stored records, so a malicious or truncated
|
|
25
|
+
* body fails instead of expanding without limit.
|
|
26
|
+
*/
|
|
27
|
+
/** Envelope field carrying the base64 gzip of the payload's JSON. */
|
|
28
|
+
export const COMPRESSED_PAYLOAD_FIELD = "payloadGz";
|
|
29
|
+
/** Envelope field carrying the UTF-8 byte length of that JSON before compression. */
|
|
30
|
+
export const COMPRESSED_PAYLOAD_BYTES_FIELD = "payloadBytes";
|
|
31
|
+
/**
|
|
32
|
+
* Defaults, resolved through the shared resolveCompressionOption like every
|
|
33
|
+
* other compression option. Below a few KB the gzip header, the base64
|
|
34
|
+
* overhead and the round trip through CompressionStream cost more than the
|
|
35
|
+
* bytes they save. The caller passes `option ?? false`, because unlike the
|
|
36
|
+
* at-rest stores this one is off unless asked for.
|
|
37
|
+
*/
|
|
38
|
+
export const DEFAULT_REQUEST_COMPRESSION_SETTINGS = { minBytes: 4096 };
|
|
39
|
+
/**
|
|
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.
|
|
43
|
+
*/
|
|
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. */
|
|
46
|
+
const bytesToBase64 = (bytes) => {
|
|
47
|
+
const chunkSize = 0x8000;
|
|
48
|
+
let binary = "";
|
|
49
|
+
for (let i = 0; i < bytes.length; i += chunkSize) {
|
|
50
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
|
|
51
|
+
}
|
|
52
|
+
return btoa(binary);
|
|
53
|
+
};
|
|
54
|
+
/** True when this runtime can compress request payloads (browsers, and Node 18+). */
|
|
55
|
+
export const isRequestCompressionAvailable = () => typeof CompressionStream !== "undefined" && typeof btoa !== "undefined";
|
|
56
|
+
/**
|
|
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.
|
|
67
|
+
*/
|
|
68
|
+
export const compressPayloadJson = async (json, minBytes) => {
|
|
69
|
+
const encoded = new TextEncoder().encode(json);
|
|
70
|
+
if (encoded.length < minBytes)
|
|
71
|
+
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);
|
|
75
|
+
if (base64.length >= encoded.length)
|
|
76
|
+
return null;
|
|
77
|
+
return {
|
|
78
|
+
[COMPRESSED_PAYLOAD_FIELD]: base64,
|
|
79
|
+
[COMPRESSED_PAYLOAD_BYTES_FIELD]: encoded.length,
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Restores a payload the caller compressed, for request mocking
|
|
84
|
+
* (LambderMSW), so a mock handler receives the same payload the server
|
|
85
|
+
* would. The server does NOT use this: it decompresses through zlib, whose
|
|
86
|
+
* bounded output is what makes an untrusted body safe to expand.
|
|
87
|
+
*/
|
|
88
|
+
export const decompressPayloadJson = async (payloadGz) => {
|
|
89
|
+
const binary = atob(payloadGz);
|
|
90
|
+
const bytes = new Uint8Array(binary.length);
|
|
91
|
+
for (let i = 0; i < binary.length; i += 1) {
|
|
92
|
+
bytes[i] = binary.charCodeAt(i);
|
|
93
|
+
}
|
|
94
|
+
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"));
|
|
95
|
+
return JSON.parse(await new Response(stream).text());
|
|
96
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
2
|
-
import { type LambderCompressionOption } from "
|
|
2
|
+
import { type LambderCompressionOption } from "../shared/LambderCompressionOption.js";
|
|
3
3
|
export interface LambderDdbCacheOptions {
|
|
4
4
|
tableName: string;
|
|
5
5
|
region?: string;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { BatchWriteItemCommand, DeleteItemCommand, DynamoDBClient, GetItemCommand, PutItemCommand, QueryCommand, } from "@aws-sdk/client-dynamodb";
|
|
2
2
|
import { getCrypto } from "../shared/node-polyfills.js";
|
|
3
|
-
import {
|
|
3
|
+
import { compressText, restoreBoundedText } from "../shared/LambderCompressionCodec.js";
|
|
4
|
+
import { resolveCompressionOption, } from "../shared/LambderCompressionOption.js";
|
|
4
5
|
import { LRUCache } from "lru-cache";
|
|
5
6
|
const DEFAULT_TTL_SECONDS = 365 * 24 * 60 * 60;
|
|
6
7
|
const DEFAULT_CHUNK_BYTES = 350 * 1024;
|
|
@@ -17,7 +18,7 @@ const COMPRESSION_DEFAULTS = { minBytes: 0, quality: 5 };
|
|
|
17
18
|
// sit in a frontend bundle's import graph (via the package root) without
|
|
18
19
|
// breaking; using the cache at runtime still requires Node. Brotli helpers
|
|
19
20
|
// are shared with LambderDdbIdempotency and LambderSessionManager via
|
|
20
|
-
//
|
|
21
|
+
// ../shared/LambderCompressionCodec.js.
|
|
21
22
|
const requireCrypto = async () => {
|
|
22
23
|
const crypto = await getCrypto();
|
|
23
24
|
if (!crypto)
|
|
@@ -152,7 +153,7 @@ export class LambderDdbCache {
|
|
|
152
153
|
}
|
|
153
154
|
const brotli = this.compression && input.length >= this.compression.minBytes ? this.compression : null;
|
|
154
155
|
const encoding = brotli ? "br" : "identity";
|
|
155
|
-
const stored = brotli ? await
|
|
156
|
+
const stored = brotli ? await compressText(input, "br", brotli.quality) : input;
|
|
156
157
|
if (stored.length > this.maxValueBytes) {
|
|
157
158
|
throw new Error(`Stored cache value exceeds maxValueBytes (${stored.length} > ${this.maxValueBytes})`);
|
|
158
159
|
}
|
|
@@ -452,7 +453,7 @@ export class LambderDdbCache {
|
|
|
452
453
|
}
|
|
453
454
|
/** The JSON text of a stored payload. */
|
|
454
455
|
async decode(stored, encoding, uncompressedBytes) {
|
|
455
|
-
return encoding === "br" ? await
|
|
456
|
+
return encoding === "br" ? await restoreBoundedText(stored, uncompressedBytes, "br") : stored.toString("utf8");
|
|
456
457
|
}
|
|
457
458
|
remember(key, stored, encoding, uncompressedBytes, expiresAt) {
|
|
458
459
|
if (!this.memory)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
2
|
-
import { type LambderCompressionOption } from "
|
|
2
|
+
import { type LambderCompressionOption } from "../shared/LambderCompressionOption.js";
|
|
3
3
|
export interface LambderDdbIdempotencyOptions {
|
|
4
4
|
tableName: string;
|
|
5
5
|
region?: string;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import crypto from "crypto";
|
|
2
2
|
import { DynamoDBClient, PutItemCommand, GetItemCommand, DeleteItemCommand, } from "@aws-sdk/client-dynamodb";
|
|
3
|
-
import {
|
|
3
|
+
import { compressText, restoreBoundedText } from "../shared/LambderCompressionCodec.js";
|
|
4
|
+
import { resolveCompressionOption, } from "../shared/LambderCompressionOption.js";
|
|
4
5
|
/** Bodies of 1KB or more are stored Brotli-compressed by default; smaller ones stay plain. */
|
|
5
6
|
const COMPRESSION_DEFAULTS = { minBytes: 1024, quality: 5 };
|
|
6
7
|
/**
|
|
@@ -66,7 +67,7 @@ export class LambderDdbIdempotency {
|
|
|
66
67
|
static async readItemBody(item) {
|
|
67
68
|
const compressed = item.bodyBr?.B;
|
|
68
69
|
if (compressed)
|
|
69
|
-
return await
|
|
70
|
+
return await restoreBoundedText(compressed, Number(item.bodyBytes?.N ?? 0), "br");
|
|
70
71
|
return item.body?.S ?? "";
|
|
71
72
|
}
|
|
72
73
|
/**
|
|
@@ -157,7 +158,7 @@ export class LambderDdbIdempotency {
|
|
|
157
158
|
const rawBody = Buffer.from(body, "utf8");
|
|
158
159
|
let bodyAttributes;
|
|
159
160
|
if (this.compression && rawBody.byteLength >= this.compression.minBytes) {
|
|
160
|
-
const compressed = await
|
|
161
|
+
const compressed = await compressText(rawBody, "br", this.compression.quality);
|
|
161
162
|
if (compressed.byteLength > MAX_STORED_BODY_BYTES)
|
|
162
163
|
return "too-large";
|
|
163
164
|
// bodyBytes bounds and verifies decompression on read.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lambder",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.7.1",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -51,26 +51,40 @@
|
|
|
51
51
|
"url": "https://github.com/nesovera/lambder.git"
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
|
-
"@aws-sdk/client-dynamodb": "^3.574.0",
|
|
55
|
-
"@aws-sdk/lib-dynamodb": "^3.574.0",
|
|
56
54
|
"cookie": "^1.0.2",
|
|
57
55
|
"js-cookie": "^3.0.5",
|
|
58
56
|
"lru-cache": "^11.5.2",
|
|
59
57
|
"mime-types": "^2.1.35",
|
|
60
|
-
"path-to-regexp": "^6.2.1"
|
|
61
|
-
"zod": "^4.1.12"
|
|
58
|
+
"path-to-regexp": "^6.2.1"
|
|
62
59
|
},
|
|
63
60
|
"peerDependencies": {
|
|
61
|
+
"@aws-sdk/client-dynamodb": "^3.574.0",
|
|
64
62
|
"@aws-sdk/client-s3": "^3.574.0",
|
|
65
|
-
"
|
|
63
|
+
"@aws-sdk/lib-dynamodb": "^3.574.0",
|
|
64
|
+
"msw": "^2.0.0",
|
|
65
|
+
"zod": "^4.1.12"
|
|
66
66
|
},
|
|
67
67
|
"peerDependenciesMeta": {
|
|
68
|
+
"@aws-sdk/client-dynamodb": {
|
|
69
|
+
"optional": true
|
|
70
|
+
},
|
|
71
|
+
"@aws-sdk/client-s3": {
|
|
72
|
+
"optional": true
|
|
73
|
+
},
|
|
74
|
+
"@aws-sdk/lib-dynamodb": {
|
|
75
|
+
"optional": true
|
|
76
|
+
},
|
|
68
77
|
"msw": {
|
|
69
78
|
"optional": true
|
|
79
|
+
},
|
|
80
|
+
"zod": {
|
|
81
|
+
"optional": true
|
|
70
82
|
}
|
|
71
83
|
},
|
|
72
84
|
"devDependencies": {
|
|
85
|
+
"@aws-sdk/client-dynamodb": "^3.913.0",
|
|
73
86
|
"@aws-sdk/client-s3": "^3.1127.0",
|
|
87
|
+
"@aws-sdk/lib-dynamodb": "^3.913.0",
|
|
74
88
|
"@types/aws-lambda": "^8.10.136",
|
|
75
89
|
"@types/cookie": "^0.6.0",
|
|
76
90
|
"@types/js-cookie": "^3.0.6",
|
|
@@ -82,6 +96,7 @@
|
|
|
82
96
|
"aws-sdk-client-mock": "^4.1.0",
|
|
83
97
|
"eslint": "^8.57.0",
|
|
84
98
|
"typescript": "^5.9.3",
|
|
85
|
-
"vitest": "^3.2.4"
|
|
99
|
+
"vitest": "^3.2.4",
|
|
100
|
+
"zod": "^4.1.12"
|
|
86
101
|
}
|
|
87
102
|
}
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The compression option every store shares. `true` is on with the store's
|
|
3
|
-
* defaults, `false` is off, an object overrides the defaults: `minBytes` is
|
|
4
|
-
* the UTF-8 size from which a value is stored compressed (0: always),
|
|
5
|
-
* `quality` is Brotli 0-11. Values below minBytes are stored plain, and a
|
|
6
|
-
* store reads records of either shape, so the option can be switched on or
|
|
7
|
-
* off on a live table: records written under the other setting keep
|
|
8
|
-
* reading, and each is rewritten in the current shape on its next write.
|
|
9
|
-
*/
|
|
10
|
-
export type LambderCompressionConfig = {
|
|
11
|
-
minBytes?: number;
|
|
12
|
-
quality?: number;
|
|
13
|
-
};
|
|
14
|
-
export type LambderCompressionOption = boolean | LambderCompressionConfig;
|
|
15
|
-
export type LambderCompressionSettings = Required<LambderCompressionConfig>;
|
|
16
|
-
/** Resolves a store's compression option against its defaults: null when off. */
|
|
17
|
-
export declare const resolveCompressionOption: (option: LambderCompressionOption | undefined, defaults: LambderCompressionSettings) => LambderCompressionSettings | null;
|
|
18
|
-
export declare const brotliCompressText: (input: Buffer, quality: number) => Promise<Buffer>;
|
|
19
|
-
/**
|
|
20
|
-
* Restores text stored as Brotli bytes beside its declared UTF-8 byte length.
|
|
21
|
-
* The length bounds the decompression and the output must match it exactly,
|
|
22
|
-
* so a truncated or tampered record fails instead of decoding to something
|
|
23
|
-
* else.
|
|
24
|
-
*/
|
|
25
|
-
export declare const brotliRestoreText: (compressed: Uint8Array, declaredBytes: number) => Promise<string>;
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import { getZlib } from "../shared/node-polyfills.js";
|
|
2
|
-
/** Resolves a store's compression option against its defaults: null when off. */
|
|
3
|
-
export const resolveCompressionOption = (option, defaults) => {
|
|
4
|
-
if (option === false)
|
|
5
|
-
return null;
|
|
6
|
-
const config = option === true || option === undefined ? {} : option;
|
|
7
|
-
const settings = { minBytes: config.minBytes ?? defaults.minBytes, quality: config.quality ?? defaults.quality };
|
|
8
|
-
if (!Number.isSafeInteger(settings.minBytes) || settings.minBytes < 0) {
|
|
9
|
-
throw new Error("compression.minBytes must be a non-negative integer");
|
|
10
|
-
}
|
|
11
|
-
if (!Number.isInteger(settings.quality) || settings.quality < 0 || settings.quality > 11) {
|
|
12
|
-
throw new Error("compression.quality must be an integer from 0 to 11");
|
|
13
|
-
}
|
|
14
|
-
return settings;
|
|
15
|
-
};
|
|
16
|
-
const requireZlib = async () => {
|
|
17
|
-
const zlib = await getZlib();
|
|
18
|
-
if (!zlib)
|
|
19
|
-
throw new Error("Lambder DDB stores require a Node.js environment.");
|
|
20
|
-
return zlib;
|
|
21
|
-
};
|
|
22
|
-
export const brotliCompressText = async (input, quality) => {
|
|
23
|
-
const zlib = await requireZlib();
|
|
24
|
-
return new Promise((resolve, reject) => {
|
|
25
|
-
zlib.brotliCompress(input, {
|
|
26
|
-
params: {
|
|
27
|
-
[zlib.constants.BROTLI_PARAM_QUALITY]: quality,
|
|
28
|
-
[zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_TEXT,
|
|
29
|
-
},
|
|
30
|
-
}, (error, output) => {
|
|
31
|
-
if (error)
|
|
32
|
-
reject(error);
|
|
33
|
-
else
|
|
34
|
-
resolve(output);
|
|
35
|
-
});
|
|
36
|
-
});
|
|
37
|
-
};
|
|
38
|
-
/**
|
|
39
|
-
* Restores text stored as Brotli bytes beside its declared UTF-8 byte length.
|
|
40
|
-
* The length bounds the decompression and the output must match it exactly,
|
|
41
|
-
* so a truncated or tampered record fails instead of decoding to something
|
|
42
|
-
* else.
|
|
43
|
-
*/
|
|
44
|
-
export const brotliRestoreText = async (compressed, declaredBytes) => {
|
|
45
|
-
if (!Number.isSafeInteger(declaredBytes) || declaredBytes <= 0) {
|
|
46
|
-
throw new Error("compressed record is missing its byte length");
|
|
47
|
-
}
|
|
48
|
-
const zlib = await requireZlib();
|
|
49
|
-
const output = await new Promise((resolve, reject) => {
|
|
50
|
-
zlib.brotliDecompress(Buffer.from(compressed), { maxOutputLength: declaredBytes }, (error, result) => {
|
|
51
|
-
if (error)
|
|
52
|
-
reject(error);
|
|
53
|
-
else
|
|
54
|
-
resolve(result);
|
|
55
|
-
});
|
|
56
|
-
});
|
|
57
|
-
if (output.length !== declaredBytes) {
|
|
58
|
-
throw new Error("decompressed length does not match the record");
|
|
59
|
-
}
|
|
60
|
-
return output.toString("utf8");
|
|
61
|
-
};
|