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.
@@ -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 "./LambderDdbCompression.js";
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 { brotliCompressText, brotliRestoreText, resolveCompressionOption, } from "./LambderDdbCompression.js";
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
- // ./LambderDdbCompression.js.
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 brotliCompressText(input, brotli.quality) : input;
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 brotliRestoreText(stored, uncompressedBytes) : stored.toString("utf8");
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 "./LambderDdbCompression.js";
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 { brotliCompressText, brotliRestoreText, resolveCompressionOption, } from "./LambderDdbCompression.js";
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 brotliRestoreText(compressed, Number(item.bodyBytes?.N ?? 0));
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 brotliCompressText(rawBody, this.compression.quality);
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.6.1",
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
- "msw": "^2.0.0"
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
- };