lambder 4.6.2 → 4.7.3
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 +70 -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 +40 -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 +3 -2
- package/dist/stores/LambderDdbCompression.d.ts +0 -25
- package/dist/stores/LambderDdbCompression.js +0 -61
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { LambderCompressionOption, LambderCompressionSettingsBase, LambderEncoding } from "../shared/LambderCompressionOption.js";
|
|
1
2
|
import type { LambderRenderContext } from "./LambderContext.js";
|
|
2
3
|
export type HttpStatusCode = 100 | 101 | 200 | 201 | 202 | 203 | 204 | 206 | 300 | 301 | 302 | 303 | 304 | 307 | 308 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 408 | 409 | 410 | 412 | 413 | 415 | 416 | 418 | 422 | 428 | 429 | 431 | 451 | 500 | 501 | 502 | 503 | 504;
|
|
3
4
|
export type LambderHeadersInput = Record<string, string | string[]>;
|
|
@@ -53,14 +54,31 @@ export declare class LambderResponse {
|
|
|
53
54
|
}
|
|
54
55
|
export declare const isCompressibleContentType: (contentType: string | undefined) => boolean;
|
|
55
56
|
export declare const acceptsEncoding: (acceptEncoding: string | undefined | null, encoding: string) => boolean;
|
|
57
|
+
/** Response-side settings: the threshold plus what the wire can negotiate. */
|
|
58
|
+
export type LambderResponseCompressionSettings = LambderCompressionSettingsBase & {
|
|
59
|
+
/** Preference order; the first the client accepts wins. */
|
|
60
|
+
encodings: LambderEncoding[];
|
|
61
|
+
/** Brotli quality 0-11, the same field the at-rest stores take. Kept low: this runs per request, and 11 is orders of magnitude slower. */
|
|
62
|
+
quality: number;
|
|
63
|
+
};
|
|
64
|
+
/** The `compression` option at creation: `true` for the defaults, `false` for off, or overrides. */
|
|
65
|
+
export type LambderResponseCompressionOption = LambderCompressionOption<LambderResponseCompressionSettings>;
|
|
56
66
|
export type LambderFinalizeOptions = {
|
|
57
|
-
compression:
|
|
58
|
-
|
|
59
|
-
};
|
|
67
|
+
/** Resolved settings, or null when compression is off: the same `Settings | null` contract the stores hold. */
|
|
68
|
+
compression: LambderResponseCompressionSettings | null;
|
|
60
69
|
etag: boolean;
|
|
61
70
|
/** Guard against Lambda's ~6MB response cap with a clear error. */
|
|
62
71
|
maxResponseBytes: number;
|
|
63
72
|
};
|
|
73
|
+
/**
|
|
74
|
+
* Brotli first: every browser that accepts it produces smaller bodies than
|
|
75
|
+
* gzip at comparable speed on quality 5, typically 15-25% on markup and
|
|
76
|
+
* prose and substantially more on the repetitive record lists API responses
|
|
77
|
+
* tend to be. That is bandwidth saved and, because the ~6MB cap applies to
|
|
78
|
+
* the encoded bytes, headroom gained. Clients that do not offer `br` fall
|
|
79
|
+
* through to gzip.
|
|
80
|
+
*/
|
|
81
|
+
export declare const DEFAULT_RESPONSE_COMPRESSION_SETTINGS: LambderResponseCompressionSettings;
|
|
64
82
|
export declare const DEFAULT_FINALIZE_OPTIONS: LambderFinalizeOptions;
|
|
65
83
|
/**
|
|
66
84
|
* Convert an intermediate LambderResponse into the final Lambda response:
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getCrypto } from "../shared/node-polyfills.js";
|
|
2
|
+
import { compressText } from "../shared/LambderCompressionCodec.js";
|
|
2
3
|
export const normalizeHeaders = (headers) => Object.fromEntries(Object.entries(headers ?? {}).map(([k, v]) => [k, Array.isArray(v) ? [...v] : [v]]));
|
|
3
4
|
/**
|
|
4
5
|
* Intermediate response object returned by all response builder methods and by
|
|
@@ -83,8 +84,21 @@ export const acceptsEncoding = (acceptEncoding, encoding) => {
|
|
|
83
84
|
return !q || Number(q.slice(2)) > 0;
|
|
84
85
|
});
|
|
85
86
|
};
|
|
87
|
+
/**
|
|
88
|
+
* Brotli first: every browser that accepts it produces smaller bodies than
|
|
89
|
+
* gzip at comparable speed on quality 5, typically 15-25% on markup and
|
|
90
|
+
* prose and substantially more on the repetitive record lists API responses
|
|
91
|
+
* tend to be. That is bandwidth saved and, because the ~6MB cap applies to
|
|
92
|
+
* the encoded bytes, headroom gained. Clients that do not offer `br` fall
|
|
93
|
+
* through to gzip.
|
|
94
|
+
*/
|
|
95
|
+
export const DEFAULT_RESPONSE_COMPRESSION_SETTINGS = {
|
|
96
|
+
minBytes: 860,
|
|
97
|
+
encodings: ["br", "gzip"],
|
|
98
|
+
quality: 5,
|
|
99
|
+
};
|
|
86
100
|
export const DEFAULT_FINALIZE_OPTIONS = {
|
|
87
|
-
compression:
|
|
101
|
+
compression: DEFAULT_RESPONSE_COMPRESSION_SETTINGS,
|
|
88
102
|
etag: true,
|
|
89
103
|
maxResponseBytes: 5_500_000,
|
|
90
104
|
};
|
|
@@ -141,18 +155,21 @@ export const finalizeResponse = async (ctx, response, options, format = "v1") =>
|
|
|
141
155
|
const alreadyEncoded = !!response.getHeader("Content-Encoding");
|
|
142
156
|
const eligibleForCompression = !alreadyEncoded && (response.compress === true ||
|
|
143
157
|
(response.compress === "auto" &&
|
|
144
|
-
options.compression !==
|
|
158
|
+
options.compression !== null &&
|
|
145
159
|
bodyBuffer.length >= options.compression.minBytes &&
|
|
146
160
|
isCompressibleContentType(contentType)));
|
|
147
161
|
if (eligibleForCompression) {
|
|
148
162
|
// Vary even when this client didn't accept an encoding, to keep caches correct.
|
|
149
163
|
response.addHeader("Vary", "Accept-Encoding");
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
164
|
+
// compress: true forces compression even with it globally off, so
|
|
165
|
+
// the settings fall back to the defaults rather than being absent.
|
|
166
|
+
const settings = options.compression ?? DEFAULT_RESPONSE_COMPRESSION_SETTINGS;
|
|
167
|
+
const acceptEncoding = getRequestHeader(ctx, "accept-encoding");
|
|
168
|
+
const encoding = settings.encodings.find((candidate) => acceptsEncoding(acceptEncoding, candidate));
|
|
169
|
+
if (encoding) {
|
|
170
|
+
// The same codec, quality and TEXT mode a stored record gets.
|
|
171
|
+
bodyBuffer = await compressText(bodyBuffer, encoding, settings.quality);
|
|
172
|
+
response.setHeader("Content-Encoding", encoding);
|
|
156
173
|
}
|
|
157
174
|
}
|
|
158
175
|
if (Buffer.isBuffer(response.body) || response.getHeader("Content-Encoding")) {
|
package/dist/index.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export { default as LambderResponseBuilder } from "./core/LambderResponseBuilder
|
|
|
9
9
|
export { default as LambderResolver } from "./core/LambderResolver.js";
|
|
10
10
|
export { default as LambderSessionManager } from "./session/LambderSessionManager.js";
|
|
11
11
|
export { default as LambderSessionController } from "./session/LambderSessionController.js";
|
|
12
|
-
export { LambderResponse, finalizeResponse, acceptsEncoding, type HttpStatusCode, type LambderHttpResponse, type LambderHttpEventFormat, type LambderHeadersInput, type LambderFinalizeOptions, } from "./core/LambderResponse.js";
|
|
12
|
+
export { LambderResponse, finalizeResponse, acceptsEncoding, type HttpStatusCode, type LambderHttpResponse, type LambderHttpEventFormat, type LambderHeadersInput, type LambderFinalizeOptions, type LambderResponseCompressionSettings, type LambderResponseCompressionOption, } from "./core/LambderResponse.js";
|
|
13
13
|
export { html, xml, raw, jsonScript, escapeHtml, renderHtmlValue, LambderSafeHtml, type LambderHtmlValue } from "./shared/LambderHtml.js";
|
|
14
14
|
export { LambderTemplatingEngine } from "./core/LambderTemplatingEngine.js";
|
|
15
15
|
export type { LambderTemplateData, LambderTemplatingEngineOptions } from "./core/LambderTemplatingEngine.js";
|
|
@@ -23,7 +23,10 @@ export { LambderS3FileSource } from "./stores/LambderS3FileSource.js";
|
|
|
23
23
|
export type { LambderS3FileSourceOptions } from "./stores/LambderS3FileSource.js";
|
|
24
24
|
export type { LambderSessionCookieOptions } from "./session/LambderSessionController.js";
|
|
25
25
|
export type { LambderSessionContext, LambderCreatedSession, LambderSessionDataRefreshConfig } from "./session/LambderSessionManager.js";
|
|
26
|
-
export
|
|
26
|
+
export { resolveCompressionOption, LAMBDER_ENCODINGS } from "./shared/LambderCompressionOption.js";
|
|
27
|
+
export type { LambderCompressionOption, LambderCompressionSettings, LambderCompressionSettingsBase, LambderEncoding, } from "./shared/LambderCompressionOption.js";
|
|
28
|
+
export { compressText, restoreBoundedText, LambderCompressionError, LAMBDER_RESTORE_FAILURES, } from "./shared/LambderCompressionCodec.js";
|
|
29
|
+
export type { LambderRestoreFailure } from "./shared/LambderCompressionCodec.js";
|
|
27
30
|
export { LambderSessionDataRefreshError, LambderSessionReadError } from "./session/LambderSessionManager.js";
|
|
28
31
|
export { LambderDdbCache } from "./stores/LambderDdbCache.js";
|
|
29
32
|
export type { LambderDdbCacheOptions, LambderDdbCacheSetOptions, LambderDdbCacheGetOrSetOptions, } from "./stores/LambderDdbCache.js";
|
|
@@ -41,5 +44,7 @@ export type { LambderLanguageMeta, LambderI18nConfig, LambderI18nInstance, Lambd
|
|
|
41
44
|
export { type ApiContractShape, type LambderApiResponse, type LambderApiResponseConfig, } from "./shared/LambderApiContract.js";
|
|
42
45
|
export type { LambderRenderContext, LambderSessionRenderContext, LambderHttpEvent } from "./core/LambderContext.js";
|
|
43
46
|
export { createContext, isV2HttpEvent } from "./core/LambderContext.js";
|
|
47
|
+
export { COMPRESSED_PAYLOAD_FIELD, COMPRESSED_PAYLOAD_BYTES_FIELD, DEFAULT_REQUEST_COMPRESSION_SETTINGS, DEFAULT_MAX_REQUEST_PAYLOAD_BYTES, } from "./shared/LambderRequestPayload.js";
|
|
48
|
+
export type { LambderCompressedPayload, LambderRequestCompressionOption, LambderRequestCompressionSettings, } from "./shared/LambderRequestPayload.js";
|
|
44
49
|
export { serializeCookie, serializeClearCookie, resolveCookieDomain } from "./core/LambderCookie.js";
|
|
45
50
|
export type { LambderCookieOptions, LambderClearCookieOptions, LambderCookieDomain } from "./core/LambderCookie.js";
|
package/dist/index.js
CHANGED
|
@@ -18,6 +18,11 @@ export { LambderTemplatingEngine } from "./core/LambderTemplatingEngine.js";
|
|
|
18
18
|
export { LambderPublicFilesHandler } from "./core/LambderPublicFiles.js";
|
|
19
19
|
export { LambderFiles, LambderLocalFileSource } from "./core/LambderFiles.js";
|
|
20
20
|
export { LambderS3FileSource } from "./stores/LambderS3FileSource.js";
|
|
21
|
+
// Compression: the option every site shares, and the one codec behind them all.
|
|
22
|
+
export { resolveCompressionOption, LAMBDER_ENCODINGS } from "./shared/LambderCompressionOption.js";
|
|
23
|
+
// Brotli/gzip plus the bounded, length-verified restore every compressed
|
|
24
|
+
// value in Lambder (records at rest, request payloads) goes through.
|
|
25
|
+
export { compressText, restoreBoundedText, LambderCompressionError, LAMBDER_RESTORE_FAILURES, } from "./shared/LambderCompressionCodec.js";
|
|
21
26
|
export { LambderSessionDataRefreshError, LambderSessionReadError } from "./session/LambderSessionManager.js";
|
|
22
27
|
// DynamoDB-backed compressed cache (standalone, server-only)
|
|
23
28
|
export { LambderDdbCache } from "./stores/LambderDdbCache.js";
|
|
@@ -32,5 +37,7 @@ export { lambderRateLimitKey } from "./policies/LambderApiRateLimits.js";
|
|
|
32
37
|
// Typed translations (standalone, isomorphic)
|
|
33
38
|
export { createLambderI18n } from "./shared/LambderI18n.js";
|
|
34
39
|
export { createContext, isV2HttpEvent } from "./core/LambderContext.js";
|
|
40
|
+
// Request payload compression: the wire format LambderCaller and the server share.
|
|
41
|
+
export { COMPRESSED_PAYLOAD_FIELD, COMPRESSED_PAYLOAD_BYTES_FIELD, DEFAULT_REQUEST_COMPRESSION_SETTINGS, DEFAULT_MAX_REQUEST_PAYLOAD_BYTES, } from "./shared/LambderRequestPayload.js";
|
|
35
42
|
// Cookies (res.setCookie / res.clearCookie build on these; exported for code holding a LambderResponse)
|
|
36
43
|
export { serializeCookie, serializeClearCookie, resolveCookieDomain } from "./core/LambderCookie.js";
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import crypto from "crypto";
|
|
2
2
|
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
3
3
|
import { DynamoDBDocumentClient, QueryCommand, DeleteCommand, PutCommand, GetCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb";
|
|
4
|
-
import {
|
|
4
|
+
import { compressText, restoreBoundedText } from "../shared/LambderCompressionCodec.js";
|
|
5
|
+
import { resolveCompressionOption, } from "../shared/LambderCompressionOption.js";
|
|
5
6
|
/**
|
|
6
7
|
* Session compression defaults: every record compressed (see
|
|
7
8
|
* LambderCompressionOption for the option's shape and toggle semantics).
|
|
@@ -95,7 +96,7 @@ export default class LambderSessionManager {
|
|
|
95
96
|
const { data, ...item } = session;
|
|
96
97
|
const raw = this.compression && Buffer.from(JSON.stringify(data), "utf8");
|
|
97
98
|
if (this.compression && raw && raw.byteLength >= this.compression.minBytes) {
|
|
98
|
-
item.dataBr = await
|
|
99
|
+
item.dataBr = await compressText(raw, "br", this.compression.quality);
|
|
99
100
|
item.dataBytes = raw.byteLength;
|
|
100
101
|
}
|
|
101
102
|
else {
|
|
@@ -201,7 +202,7 @@ export default class LambderSessionManager {
|
|
|
201
202
|
// that fails to decode throws, which the controller treats like any
|
|
202
203
|
// malformed record: no session.
|
|
203
204
|
if (session.dataBr) {
|
|
204
|
-
session.data = JSON.parse(await
|
|
205
|
+
session.data = JSON.parse(await restoreBoundedText(session.dataBr, session.dataBytes, "br"));
|
|
205
206
|
delete session.dataBr;
|
|
206
207
|
delete session.dataBytes;
|
|
207
208
|
}
|
|
@@ -87,6 +87,8 @@ export declare const LAMBDER_REFUSAL_CODES: {
|
|
|
87
87
|
readonly invalidIdempotencyKey: "lambder/invalid-idempotency-key";
|
|
88
88
|
/** No API is registered under the requested name. */
|
|
89
89
|
readonly apiNotFound: "lambder/api-not-found";
|
|
90
|
+
/** The request's compressed payload is malformed or over the size limit (400). */
|
|
91
|
+
readonly invalidRequestPayload: "lambder/invalid-request-payload";
|
|
90
92
|
};
|
|
91
93
|
export type LambderRefusalCode = (typeof LAMBDER_REFUSAL_CODES)[keyof typeof LAMBDER_REFUSAL_CODES];
|
|
92
94
|
export type LambderRefuseOptions = {
|
|
@@ -53,6 +53,8 @@ export const LAMBDER_REFUSAL_CODES = {
|
|
|
53
53
|
invalidIdempotencyKey: "lambder/invalid-idempotency-key",
|
|
54
54
|
/** No API is registered under the requested name. */
|
|
55
55
|
apiNotFound: "lambder/api-not-found",
|
|
56
|
+
/** The request's compressed payload is malformed or over the size limit (400). */
|
|
57
|
+
invalidRequestPayload: "lambder/invalid-request-payload",
|
|
56
58
|
};
|
|
57
59
|
/**
|
|
58
60
|
* Refuse the current API call: a routine business "no" (not found, invalid
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place Lambder compresses and decompresses bytes.
|
|
3
|
+
*
|
|
4
|
+
* Five things compress: sessions, LambderDdbCache and LambderDdbIdempotency
|
|
5
|
+
* (Brotli at rest in DynamoDB), HTTP responses (Brotli or gzip, negotiated)
|
|
6
|
+
* and request payloads (gzip, because the browser's CompressionStream
|
|
7
|
+
* offers nothing else). They all compress text, so TEXT mode throughout,
|
|
8
|
+
* and they all restore it the same way: the compressed bytes beside the
|
|
9
|
+
* text's original UTF-8 byte length.
|
|
10
|
+
*
|
|
11
|
+
* That length is the safety mechanism, not bookkeeping. It bounds the
|
|
12
|
+
* decompression, so a body that would expand without limit is cut off
|
|
13
|
+
* rather than allocated, and the restored length must match it exactly, so
|
|
14
|
+
* a truncated or tampered input fails instead of decoding to something
|
|
15
|
+
* merely plausible. Every caller gets that guarantee from this one
|
|
16
|
+
* implementation: a bug fixed here is fixed for records at rest and for
|
|
17
|
+
* untrusted request bodies alike.
|
|
18
|
+
*
|
|
19
|
+
* zlib is loaded lazily through node-polyfills, so a module importing this
|
|
20
|
+
* one can still sit in a frontend bundle's import graph via the package
|
|
21
|
+
* root. The option that decides WHETHER to compress, and the encoding
|
|
22
|
+
* vocabulary, live in LambderCompressionOption, which stays free of zlib
|
|
23
|
+
* entirely so the browser entry can resolve it.
|
|
24
|
+
*/
|
|
25
|
+
import type { LambderEncoding } from "./LambderCompressionOption.js";
|
|
26
|
+
/** Why a bounded restore failed, for callers that answer rather than throw. */
|
|
27
|
+
export declare const LAMBDER_RESTORE_FAILURES: {
|
|
28
|
+
/** The declared byte length is absent or not a positive integer. */
|
|
29
|
+
readonly missingLength: "missing-length";
|
|
30
|
+
/** zlib refused the bytes: not this algorithm, truncated, or over the bound. */
|
|
31
|
+
readonly undecodable: "undecodable";
|
|
32
|
+
/** It decompressed, but not to the length it declared. */
|
|
33
|
+
readonly lengthMismatch: "length-mismatch";
|
|
34
|
+
};
|
|
35
|
+
export type LambderRestoreFailure = (typeof LAMBDER_RESTORE_FAILURES)[keyof typeof LAMBDER_RESTORE_FAILURES];
|
|
36
|
+
/**
|
|
37
|
+
* A restore that could not be trusted. Stores let it propagate (any throw
|
|
38
|
+
* means "unusable record"); the request pipeline catches it and turns
|
|
39
|
+
* `reason` into a client-facing refusal.
|
|
40
|
+
*/
|
|
41
|
+
export declare class LambderCompressionError extends Error {
|
|
42
|
+
readonly reason: LambderRestoreFailure;
|
|
43
|
+
constructor(reason: LambderRestoreFailure, message: string, options?: ErrorOptions);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Compresses text. `quality` is the Brotli quality (0-11) and is ignored by
|
|
47
|
+
* gzip, which has no comparable knob worth exposing.
|
|
48
|
+
*/
|
|
49
|
+
export declare const compressText: (input: Buffer, encoding: LambderEncoding, quality: number) => Promise<Buffer>;
|
|
50
|
+
/**
|
|
51
|
+
* Restores text from compressed bytes beside the declared UTF-8 byte length
|
|
52
|
+
* of the original, bounded and verified by that length. Throws
|
|
53
|
+
* LambderCompressionError on anything it cannot vouch for.
|
|
54
|
+
*/
|
|
55
|
+
export declare const restoreBoundedText: (compressed: Uint8Array, declaredBytes: number, encoding: LambderEncoding) => Promise<string>;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place Lambder compresses and decompresses bytes.
|
|
3
|
+
*
|
|
4
|
+
* Five things compress: sessions, LambderDdbCache and LambderDdbIdempotency
|
|
5
|
+
* (Brotli at rest in DynamoDB), HTTP responses (Brotli or gzip, negotiated)
|
|
6
|
+
* and request payloads (gzip, because the browser's CompressionStream
|
|
7
|
+
* offers nothing else). They all compress text, so TEXT mode throughout,
|
|
8
|
+
* and they all restore it the same way: the compressed bytes beside the
|
|
9
|
+
* text's original UTF-8 byte length.
|
|
10
|
+
*
|
|
11
|
+
* That length is the safety mechanism, not bookkeeping. It bounds the
|
|
12
|
+
* decompression, so a body that would expand without limit is cut off
|
|
13
|
+
* rather than allocated, and the restored length must match it exactly, so
|
|
14
|
+
* a truncated or tampered input fails instead of decoding to something
|
|
15
|
+
* merely plausible. Every caller gets that guarantee from this one
|
|
16
|
+
* implementation: a bug fixed here is fixed for records at rest and for
|
|
17
|
+
* untrusted request bodies alike.
|
|
18
|
+
*
|
|
19
|
+
* zlib is loaded lazily through node-polyfills, so a module importing this
|
|
20
|
+
* one can still sit in a frontend bundle's import graph via the package
|
|
21
|
+
* root. The option that decides WHETHER to compress, and the encoding
|
|
22
|
+
* vocabulary, live in LambderCompressionOption, which stays free of zlib
|
|
23
|
+
* entirely so the browser entry can resolve it.
|
|
24
|
+
*/
|
|
25
|
+
import { getZlib } from "./node-polyfills.js";
|
|
26
|
+
/** Why a bounded restore failed, for callers that answer rather than throw. */
|
|
27
|
+
export const LAMBDER_RESTORE_FAILURES = {
|
|
28
|
+
/** The declared byte length is absent or not a positive integer. */
|
|
29
|
+
missingLength: "missing-length",
|
|
30
|
+
/** zlib refused the bytes: not this algorithm, truncated, or over the bound. */
|
|
31
|
+
undecodable: "undecodable",
|
|
32
|
+
/** It decompressed, but not to the length it declared. */
|
|
33
|
+
lengthMismatch: "length-mismatch",
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* A restore that could not be trusted. Stores let it propagate (any throw
|
|
37
|
+
* means "unusable record"); the request pipeline catches it and turns
|
|
38
|
+
* `reason` into a client-facing refusal.
|
|
39
|
+
*/
|
|
40
|
+
export class LambderCompressionError extends Error {
|
|
41
|
+
reason;
|
|
42
|
+
constructor(reason, message, options) {
|
|
43
|
+
super(message, options);
|
|
44
|
+
this.name = "LambderCompressionError";
|
|
45
|
+
this.reason = reason;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// Not a LambderCompressionError: nothing was wrong with the bytes, so there
|
|
49
|
+
// is no restore `reason` to report. Callers that map reasons fall through to
|
|
50
|
+
// their generic failure, which is the honest answer here.
|
|
51
|
+
const requireZlib = async () => {
|
|
52
|
+
const zlib = await getZlib();
|
|
53
|
+
if (!zlib)
|
|
54
|
+
throw new Error("Lambder compression requires a Node.js environment.");
|
|
55
|
+
return zlib;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Compresses text. `quality` is the Brotli quality (0-11) and is ignored by
|
|
59
|
+
* gzip, which has no comparable knob worth exposing.
|
|
60
|
+
*/
|
|
61
|
+
export const compressText = async (input, encoding, quality) => {
|
|
62
|
+
const zlib = await requireZlib();
|
|
63
|
+
return await new Promise((resolve, reject) => {
|
|
64
|
+
const done = (error, output) => { if (error)
|
|
65
|
+
reject(error);
|
|
66
|
+
else
|
|
67
|
+
resolve(output); };
|
|
68
|
+
if (encoding === "br") {
|
|
69
|
+
zlib.brotliCompress(input, {
|
|
70
|
+
params: {
|
|
71
|
+
[zlib.constants.BROTLI_PARAM_QUALITY]: quality,
|
|
72
|
+
[zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_TEXT,
|
|
73
|
+
},
|
|
74
|
+
}, done);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
zlib.gzip(input, done);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Restores text from compressed bytes beside the declared UTF-8 byte length
|
|
83
|
+
* of the original, bounded and verified by that length. Throws
|
|
84
|
+
* LambderCompressionError on anything it cannot vouch for.
|
|
85
|
+
*/
|
|
86
|
+
export const restoreBoundedText = async (compressed, declaredBytes, encoding) => {
|
|
87
|
+
if (!Number.isSafeInteger(declaredBytes) || declaredBytes <= 0) {
|
|
88
|
+
throw new LambderCompressionError(LAMBDER_RESTORE_FAILURES.missingLength, "compressed value is missing its byte length");
|
|
89
|
+
}
|
|
90
|
+
const zlib = await requireZlib();
|
|
91
|
+
let output;
|
|
92
|
+
try {
|
|
93
|
+
// zlib reads any Uint8Array in place; a request-sized body is not copied first.
|
|
94
|
+
output = await new Promise((resolve, reject) => {
|
|
95
|
+
const done = (error, result) => { if (error)
|
|
96
|
+
reject(error);
|
|
97
|
+
else
|
|
98
|
+
resolve(result); };
|
|
99
|
+
// maxOutputLength is the bound: zlib stops rather than allocating past it.
|
|
100
|
+
if (encoding === "br")
|
|
101
|
+
zlib.brotliDecompress(compressed, { maxOutputLength: declaredBytes }, done);
|
|
102
|
+
else
|
|
103
|
+
zlib.gunzip(compressed, { maxOutputLength: declaredBytes }, done);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
throw new LambderCompressionError(LAMBDER_RESTORE_FAILURES.undecodable, "compressed value could not be decompressed", { cause: err });
|
|
108
|
+
}
|
|
109
|
+
if (output.length !== declaredBytes) {
|
|
110
|
+
throw new LambderCompressionError(LAMBDER_RESTORE_FAILURES.lengthMismatch, "decompressed length does not match the declared length");
|
|
111
|
+
}
|
|
112
|
+
return output.toString("utf8");
|
|
113
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The compression option every part of Lambder speaks, and the one function
|
|
3
|
+
* that resolves it.
|
|
4
|
+
*
|
|
5
|
+
* Five places compress something: sessions, LambderDdbCache and
|
|
6
|
+
* LambderDdbIdempotency (Brotli at rest in DynamoDB), HTTP responses
|
|
7
|
+
* (Brotli/gzip on the wire) and request payloads (gzip on the wire). They
|
|
8
|
+
* differ in what they can be tuned with, so each declares its own settings
|
|
9
|
+
* type, but they share one vocabulary and one resolution: `true` is on with
|
|
10
|
+
* that site's defaults, `false` is off, an object overrides individual
|
|
11
|
+
* fields, and `minBytes` is always the size from which a value is
|
|
12
|
+
* compressed (0: always). Resolved settings are `null` when off, so every
|
|
13
|
+
* consumer holds `Settings | null` and reads `minBytes` the same way.
|
|
14
|
+
*
|
|
15
|
+
* Nothing here touches zlib, so the browser entry can resolve the caller's
|
|
16
|
+
* option without pulling Node built-ins into the bundle; the compression
|
|
17
|
+
* primitives themselves live in LambderCompressionCodec, which does load zlib.
|
|
18
|
+
*/
|
|
19
|
+
/** Algorithms Lambder can produce. Brotli at rest and preferred on responses; gzip everywhere a browser has to do the compressing. */
|
|
20
|
+
export declare const LAMBDER_ENCODINGS: readonly ["br", "gzip"];
|
|
21
|
+
export type LambderEncoding = (typeof LAMBDER_ENCODINGS)[number];
|
|
22
|
+
/** Fields common to every site's settings. Sites add their own (`quality`, `encodings`). */
|
|
23
|
+
export type LambderCompressionSettingsBase = {
|
|
24
|
+
minBytes: number;
|
|
25
|
+
};
|
|
26
|
+
/** Tuning for Brotli-at-rest: the shape sessions and the DynamoDB stores take. */
|
|
27
|
+
export type LambderCompressionSettings = LambderCompressionSettingsBase & {
|
|
28
|
+
/** Brotli quality 0-11. */
|
|
29
|
+
quality: number;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* The option a site accepts: `true` for that site's defaults, `false` for
|
|
33
|
+
* off, or an object overriding any of its settings. Parameterized by the
|
|
34
|
+
* site's settings; the default is the at-rest shape the stores and sessions
|
|
35
|
+
* take, and the request and response sites alias their own.
|
|
36
|
+
*/
|
|
37
|
+
export type LambderCompressionOption<TSettings extends LambderCompressionSettingsBase = LambderCompressionSettings> = boolean | Partial<TSettings>;
|
|
38
|
+
/**
|
|
39
|
+
* Resolves one site's option against its defaults: `null` when off,
|
|
40
|
+
* otherwise the defaults with any supplied fields on top. `undefined` means
|
|
41
|
+
* "unspecified", so a site whose default is off passes `option ?? false`
|
|
42
|
+
* and one whose default is on passes the option through. A field set to
|
|
43
|
+
* `undefined` inside the object is unspecified too, so `{ minBytes:
|
|
44
|
+
* config.threshold }` with an optional threshold keeps the default.
|
|
45
|
+
*
|
|
46
|
+
* Validation is deliberately at resolution (construction) rather than at
|
|
47
|
+
* use: a misconfigured threshold, quality or encoding list is a startup
|
|
48
|
+
* error, not a surprise on some later request. The resolver knows the whole
|
|
49
|
+
* shared vocabulary and checks each field a site carries.
|
|
50
|
+
*/
|
|
51
|
+
export declare const resolveCompressionOption: <TSettings extends LambderCompressionSettingsBase>(option: LambderCompressionOption<TSettings> | undefined, defaults: TSettings) => TSettings | null;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The compression option every part of Lambder speaks, and the one function
|
|
3
|
+
* that resolves it.
|
|
4
|
+
*
|
|
5
|
+
* Five places compress something: sessions, LambderDdbCache and
|
|
6
|
+
* LambderDdbIdempotency (Brotli at rest in DynamoDB), HTTP responses
|
|
7
|
+
* (Brotli/gzip on the wire) and request payloads (gzip on the wire). They
|
|
8
|
+
* differ in what they can be tuned with, so each declares its own settings
|
|
9
|
+
* type, but they share one vocabulary and one resolution: `true` is on with
|
|
10
|
+
* that site's defaults, `false` is off, an object overrides individual
|
|
11
|
+
* fields, and `minBytes` is always the size from which a value is
|
|
12
|
+
* compressed (0: always). Resolved settings are `null` when off, so every
|
|
13
|
+
* consumer holds `Settings | null` and reads `minBytes` the same way.
|
|
14
|
+
*
|
|
15
|
+
* Nothing here touches zlib, so the browser entry can resolve the caller's
|
|
16
|
+
* option without pulling Node built-ins into the bundle; the compression
|
|
17
|
+
* primitives themselves live in LambderCompressionCodec, which does load zlib.
|
|
18
|
+
*/
|
|
19
|
+
/** Algorithms Lambder can produce. Brotli at rest and preferred on responses; gzip everywhere a browser has to do the compressing. */
|
|
20
|
+
export const LAMBDER_ENCODINGS = ["br", "gzip"];
|
|
21
|
+
/**
|
|
22
|
+
* Resolves one site's option against its defaults: `null` when off,
|
|
23
|
+
* otherwise the defaults with any supplied fields on top. `undefined` means
|
|
24
|
+
* "unspecified", so a site whose default is off passes `option ?? false`
|
|
25
|
+
* and one whose default is on passes the option through. A field set to
|
|
26
|
+
* `undefined` inside the object is unspecified too, so `{ minBytes:
|
|
27
|
+
* config.threshold }` with an optional threshold keeps the default.
|
|
28
|
+
*
|
|
29
|
+
* Validation is deliberately at resolution (construction) rather than at
|
|
30
|
+
* use: a misconfigured threshold, quality or encoding list is a startup
|
|
31
|
+
* error, not a surprise on some later request. The resolver knows the whole
|
|
32
|
+
* shared vocabulary and checks each field a site carries.
|
|
33
|
+
*/
|
|
34
|
+
export const resolveCompressionOption = (option, defaults) => {
|
|
35
|
+
if (option === false)
|
|
36
|
+
return null;
|
|
37
|
+
const config = option === true || option === undefined ? {} : option;
|
|
38
|
+
const overrides = Object.fromEntries(Object.entries(config).filter(([, value]) => value !== undefined));
|
|
39
|
+
const settings = { ...defaults, ...overrides };
|
|
40
|
+
if (!Number.isSafeInteger(settings.minBytes) || settings.minBytes < 0) {
|
|
41
|
+
throw new Error("compression.minBytes must be a non-negative integer");
|
|
42
|
+
}
|
|
43
|
+
if (settings.quality !== undefined && (!Number.isInteger(settings.quality) || settings.quality < 0 || settings.quality > 11)) {
|
|
44
|
+
throw new Error("compression.quality must be an integer from 0 to 11");
|
|
45
|
+
}
|
|
46
|
+
if (settings.encodings !== undefined && (!Array.isArray(settings.encodings)
|
|
47
|
+
|| settings.encodings.length === 0
|
|
48
|
+
|| settings.encodings.some((encoding) => !LAMBDER_ENCODINGS.includes(encoding)))) {
|
|
49
|
+
throw new Error(`compression.encodings must be a non-empty list of ${LAMBDER_ENCODINGS.map((e) => `"${e}"`).join(", ")}`);
|
|
50
|
+
}
|
|
51
|
+
return settings;
|
|
52
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
import type { LambderCompressionOption } from "./LambderCompressionOption.js";
|
|
28
|
+
/** Envelope field carrying the base64 gzip of the payload's JSON. */
|
|
29
|
+
export declare const COMPRESSED_PAYLOAD_FIELD = "payloadGz";
|
|
30
|
+
/** Envelope field carrying the UTF-8 byte length of that JSON before compression. */
|
|
31
|
+
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;
|
|
35
|
+
[COMPRESSED_PAYLOAD_BYTES_FIELD]: number;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Caller-side settings. Only a threshold: the browser's CompressionStream
|
|
39
|
+
* exposes no quality knob, so there is nothing else to tune.
|
|
40
|
+
*/
|
|
41
|
+
export type LambderRequestCompressionSettings = {
|
|
42
|
+
minBytes: number;
|
|
43
|
+
};
|
|
44
|
+
export type LambderRequestCompressionOption = LambderCompressionOption<LambderRequestCompressionSettings>;
|
|
45
|
+
/**
|
|
46
|
+
* Defaults, resolved through the shared resolveCompressionOption like every
|
|
47
|
+
* other compression option. Below a few KB the gzip header, the base64
|
|
48
|
+
* overhead and the round trip through CompressionStream cost more than the
|
|
49
|
+
* bytes they save. The caller passes `option ?? false`, because unlike the
|
|
50
|
+
* at-rest stores this one is off unless asked for.
|
|
51
|
+
*/
|
|
52
|
+
export declare const DEFAULT_REQUEST_COMPRESSION_SETTINGS: LambderRequestCompressionSettings;
|
|
53
|
+
/**
|
|
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.
|
|
57
|
+
*/
|
|
58
|
+
export declare const DEFAULT_MAX_REQUEST_PAYLOAD_BYTES = 20000000;
|
|
59
|
+
/** True when this runtime can compress request payloads (browsers, and Node 18+). */
|
|
60
|
+
export declare const isRequestCompressionAvailable: () => boolean;
|
|
61
|
+
/**
|
|
62
|
+
* 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.
|
|
72
|
+
*/
|
|
73
|
+
export declare const compressPayloadJson: (json: string, minBytes: number) => Promise<LambderCompressedPayload | null>;
|
|
74
|
+
/**
|
|
75
|
+
* Restores a payload the caller compressed, for request mocking
|
|
76
|
+
* (LambderMSW), so a mock handler receives the same payload the server
|
|
77
|
+
* would. The server does NOT use this: it decompresses through zlib, whose
|
|
78
|
+
* bounded output is what makes an untrusted body safe to expand.
|
|
79
|
+
*/
|
|
80
|
+
export declare const decompressPayloadJson: (payloadGz: string) => Promise<unknown>;
|