lambder 3.8.1 → 4.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 +79 -14
- package/dist/{LambderCaller.d.ts → client/LambderCaller.d.ts} +30 -12
- package/dist/{LambderCaller.js → client/LambderCaller.js} +21 -12
- package/dist/{LambderMSW.d.ts → client/LambderMSW.d.ts} +1 -1
- package/dist/client.d.ts +16 -0
- package/dist/client.js +17 -0
- package/dist/{Lambder.d.ts → core/Lambder.d.ts} +45 -20
- package/dist/{Lambder.js → core/Lambder.js} +28 -13
- package/dist/{LambderContext.d.ts → core/LambderContext.d.ts} +9 -3
- package/dist/{LambderContext.js → core/LambderContext.js} +1 -0
- package/dist/{LambderPublicFiles.js → core/LambderPublicFiles.js} +1 -1
- package/dist/{LambderResponse.js → core/LambderResponse.js} +1 -1
- package/dist/{LambderResponseBuilder.d.ts → core/LambderResponseBuilder.d.ts} +3 -15
- package/dist/{LambderResponseBuilder.js → core/LambderResponseBuilder.js} +2 -2
- package/dist/{LambderTemplatingEngine.d.ts → core/LambderTemplatingEngine.d.ts} +1 -1
- package/dist/{LambderTemplatingEngine.js → core/LambderTemplatingEngine.js} +2 -2
- package/dist/index.d.ts +36 -35
- package/dist/index.js +21 -20
- package/dist/policies/LambderApiGuards.d.ts +221 -0
- package/dist/policies/LambderApiGuards.js +79 -0
- package/dist/policies/LambderApiIdempotency.d.ts +58 -0
- package/dist/policies/LambderApiIdempotency.js +215 -0
- package/dist/policies/LambderApiPolicies.d.ts +40 -0
- package/dist/policies/LambderApiPolicies.js +47 -0
- package/dist/policies/LambderApiRateLimits.d.ts +90 -0
- package/dist/policies/LambderApiRateLimits.js +77 -0
- package/dist/{LambderSessionController.d.ts → session/LambderSessionController.d.ts} +2 -1
- package/dist/{LambderSessionController.js → session/LambderSessionController.js} +16 -11
- package/dist/{LambderSessionManager.d.ts → session/LambderSessionManager.d.ts} +38 -4
- package/dist/{LambderSessionManager.js → session/LambderSessionManager.js} +49 -16
- package/dist/{LambderApiContract.d.ts → shared/LambderApiContract.d.ts} +14 -0
- package/dist/{LambderApiError.d.ts → shared/LambderApiError.d.ts} +1 -1
- package/dist/{LambderDdbCache.js → stores/LambderDdbCache.js} +7 -38
- package/dist/stores/LambderDdbCompression.d.ts +3 -0
- package/dist/stores/LambderDdbCompression.js +39 -0
- package/dist/{LambderDdbIdempotency.d.ts → stores/LambderDdbIdempotency.d.ts} +41 -12
- package/dist/{LambderDdbIdempotency.js → stores/LambderDdbIdempotency.js} +100 -11
- package/dist/{LambderDdbRateLimiter.js → stores/LambderDdbRateLimiter.js} +5 -1
- package/dist/testing.d.ts +9 -0
- package/dist/testing.js +8 -0
- package/package.json +19 -1
- package/dist/LambderApiPolicies.d.ts +0 -212
- package/dist/LambderApiPolicies.js +0 -228
- /package/dist/{LambderMSW.js → client/LambderMSW.js} +0 -0
- /package/dist/{LambderCors.d.ts → core/LambderCors.d.ts} +0 -0
- /package/dist/{LambderCors.js → core/LambderCors.js} +0 -0
- /package/dist/{LambderPublicFiles.d.ts → core/LambderPublicFiles.d.ts} +0 -0
- /package/dist/{LambderResolver.d.ts → core/LambderResolver.d.ts} +0 -0
- /package/dist/{LambderResolver.js → core/LambderResolver.js} +0 -0
- /package/dist/{LambderResponse.d.ts → core/LambderResponse.d.ts} +0 -0
- /package/dist/{LambderRouting.d.ts → core/LambderRouting.d.ts} +0 -0
- /package/dist/{LambderRouting.js → core/LambderRouting.js} +0 -0
- /package/dist/{LambderApiContract.js → shared/LambderApiContract.js} +0 -0
- /package/dist/{LambderApiError.js → shared/LambderApiError.js} +0 -0
- /package/dist/{LambderHtml.d.ts → shared/LambderHtml.d.ts} +0 -0
- /package/dist/{LambderHtml.js → shared/LambderHtml.js} +0 -0
- /package/dist/{LambderI18n.d.ts → shared/LambderI18n.d.ts} +0 -0
- /package/dist/{LambderI18n.js → shared/LambderI18n.js} +0 -0
- /package/dist/{node-polyfills.d.ts → shared/node-polyfills.d.ts} +0 -0
- /package/dist/{node-polyfills.js → shared/node-polyfills.js} +0 -0
- /package/dist/{LambderDdbCache.d.ts → stores/LambderDdbCache.d.ts} +0 -0
- /package/dist/{LambderDdbRateLimiter.d.ts → stores/LambderDdbRateLimiter.d.ts} +0 -0
|
@@ -13,6 +13,19 @@ export class LambderSessionDataRefreshError extends Error {
|
|
|
13
13
|
this.name = "LambderSessionDataRefreshError";
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Wraps DynamoDB failures during a session read so they stay distinguishable
|
|
18
|
+
* from "no session": fetchSessionIfExists() swallows missing or invalid
|
|
19
|
+
* sessions but rethrows this. Without the distinction a transient DynamoDB
|
|
20
|
+
* error would answer sessionExpired, and the caller would then clear the
|
|
21
|
+
* client's session cookies: an infra blip forcing a real logout.
|
|
22
|
+
*/
|
|
23
|
+
export class LambderSessionReadError extends Error {
|
|
24
|
+
constructor(cause) {
|
|
25
|
+
super(`Session read failed: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
26
|
+
this.name = "LambderSessionReadError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
16
29
|
export default class LambderSessionManager {
|
|
17
30
|
tableName;
|
|
18
31
|
sessionSalt;
|
|
@@ -38,6 +51,15 @@ export default class LambderSessionManager {
|
|
|
38
51
|
.update(`${password}${this.sessionSalt}`)
|
|
39
52
|
.digest("hex");
|
|
40
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* At-rest hash for the bearer secrets (session sort-key secret, CSRF
|
|
56
|
+
* token). Fast unsalted sha256 is the right construction here: the
|
|
57
|
+
* inputs are 256-bit random values, so there is nothing to brute-force;
|
|
58
|
+
* hashing just ensures a leaked table read yields no usable cookies.
|
|
59
|
+
*/
|
|
60
|
+
hashToken(value) {
|
|
61
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
62
|
+
}
|
|
41
63
|
constantTimeCompare(a, b) {
|
|
42
64
|
if (a.length !== b.length)
|
|
43
65
|
return false;
|
|
@@ -90,22 +112,24 @@ export default class LambderSessionManager {
|
|
|
90
112
|
}
|
|
91
113
|
async createSession(sessionKey, data = {}, ttlInSeconds = 30 * 24 * 60 * 60, options) {
|
|
92
114
|
const sessionKeyHash = this.sessionUserKeyHasher(sessionKey);
|
|
93
|
-
|
|
94
|
-
|
|
115
|
+
// The sort-key SECRET goes to the client; only its hash becomes the
|
|
116
|
+
// DynamoDB range key, so the table never contains a usable token.
|
|
117
|
+
const sessionSortKeySecret = crypto.randomBytes(32).toString("hex");
|
|
118
|
+
const sessionToken = `${sessionKeyHash}:${sessionSortKeySecret}`;
|
|
95
119
|
const csrfToken = crypto.randomBytes(32).toString("hex");
|
|
96
120
|
const createdAt = Math.floor(Date.now() / 1000);
|
|
97
121
|
const lastAccessedAt = createdAt;
|
|
98
122
|
const expiresAt = Number(createdAt) + Number(ttlInSeconds);
|
|
99
123
|
const session = {
|
|
100
124
|
[this.partitionKey]: sessionKeyHash,
|
|
101
|
-
[this.sortKey]:
|
|
102
|
-
|
|
125
|
+
[this.sortKey]: this.hashToken(sessionSortKeySecret),
|
|
126
|
+
csrfTokenHash: this.hashToken(csrfToken),
|
|
103
127
|
sessionKey, data,
|
|
104
128
|
createdAt, lastAccessedAt, expiresAt, ttlInSeconds,
|
|
105
129
|
...(this.dataRefresh ? { dataExpiresAt: options?.dataExpiresAt ?? (createdAt + this.dataRefresh.ttlSeconds) } : {}),
|
|
106
130
|
};
|
|
107
131
|
await this.ddbPutItem(session);
|
|
108
|
-
return session;
|
|
132
|
+
return { session, sessionToken, csrfToken };
|
|
109
133
|
}
|
|
110
134
|
async updateSessionData(session, newData) {
|
|
111
135
|
if (!session)
|
|
@@ -124,25 +148,28 @@ export default class LambderSessionManager {
|
|
|
124
148
|
return session;
|
|
125
149
|
}
|
|
126
150
|
async getSession(sessionToken) {
|
|
127
|
-
const [sessionKeyHash,
|
|
128
|
-
if (!sessionKeyHash || !
|
|
151
|
+
const [sessionKeyHash, sessionSortKeySecret] = sessionToken.split(":");
|
|
152
|
+
if (!sessionKeyHash || !sessionSortKeySecret)
|
|
129
153
|
return null;
|
|
154
|
+
// A DynamoDB read failure propagates typed: null means "no such
|
|
155
|
+
// session", which callers translate to sessionExpired, and the caller
|
|
156
|
+
// then clears the client's session cookies. A transient infra error
|
|
157
|
+
// must surface as a 500, not force a logout.
|
|
130
158
|
let session;
|
|
131
159
|
try {
|
|
160
|
+
// The lookup itself proves possession of the raw secret: the
|
|
161
|
+
// range key is its hash, so only the true secret finds the item.
|
|
132
162
|
session = await this.ddbGetItem({
|
|
133
163
|
[this.partitionKey]: sessionKeyHash,
|
|
134
|
-
[this.sortKey]:
|
|
164
|
+
[this.sortKey]: this.hashToken(sessionSortKeySecret)
|
|
135
165
|
});
|
|
136
166
|
}
|
|
137
167
|
catch (err) {
|
|
138
|
-
|
|
168
|
+
throw new LambderSessionReadError(err);
|
|
139
169
|
}
|
|
140
|
-
// Use constant error response to prevent timing attacks
|
|
141
170
|
if (!session)
|
|
142
171
|
return null;
|
|
143
|
-
if (!session.
|
|
144
|
-
return null;
|
|
145
|
-
if (!session.csrfToken)
|
|
172
|
+
if (!session.csrfTokenHash)
|
|
146
173
|
return null;
|
|
147
174
|
if (!session.sessionKey)
|
|
148
175
|
return null;
|
|
@@ -233,9 +260,15 @@ export default class LambderSessionManager {
|
|
|
233
260
|
return false;
|
|
234
261
|
if (!sessionToken || typeof sessionToken !== "string")
|
|
235
262
|
return false;
|
|
236
|
-
|
|
263
|
+
// Presented raw secrets are checked against the stored hashes.
|
|
264
|
+
const [sessionKeyHash, sessionSortKeySecret] = sessionToken.split(":");
|
|
265
|
+
if (!sessionKeyHash || !sessionSortKeySecret)
|
|
266
|
+
return false;
|
|
267
|
+
if (!this.constantTimeCompare(String(session[this.partitionKey] ?? ""), sessionKeyHash))
|
|
268
|
+
return false;
|
|
269
|
+
if (!this.constantTimeCompare(String(session[this.sortKey] ?? ""), this.hashToken(sessionSortKeySecret)))
|
|
237
270
|
return false;
|
|
238
|
-
if (!session.
|
|
271
|
+
if (!session.csrfTokenHash)
|
|
239
272
|
return false;
|
|
240
273
|
if (!session.sessionKey)
|
|
241
274
|
return false;
|
|
@@ -246,7 +279,7 @@ export default class LambderSessionManager {
|
|
|
246
279
|
if (!skipCsrfTokenCheck) {
|
|
247
280
|
if (!csrfToken || typeof csrfToken !== "string")
|
|
248
281
|
return false;
|
|
249
|
-
if (!this.constantTimeCompare(session.
|
|
282
|
+
if (!this.constantTimeCompare(session.csrfTokenHash, this.hashToken(csrfToken)))
|
|
250
283
|
return false;
|
|
251
284
|
}
|
|
252
285
|
return true;
|
|
@@ -12,6 +12,20 @@ export type ApiContractShape = Record<string, {
|
|
|
12
12
|
/** Present when the API declares guardInput-mode guards: guard name -> value the client must send via options.guardInputs. */
|
|
13
13
|
guardInputs?: any;
|
|
14
14
|
}>;
|
|
15
|
+
/** Envelope flags/channels the server may set beside (or instead of) the payload. */
|
|
16
|
+
export type LambderApiResponseConfig = {
|
|
17
|
+
versionExpired?: boolean;
|
|
18
|
+
sessionExpired?: boolean;
|
|
19
|
+
notAuthorized?: boolean;
|
|
20
|
+
message?: any;
|
|
21
|
+
errorMessage?: any;
|
|
22
|
+
logList?: any[];
|
|
23
|
+
};
|
|
24
|
+
/** The API wire envelope both sides speak: res.api() emits it, LambderCaller parses it. */
|
|
25
|
+
export type LambderApiResponse<T> = LambderApiResponseConfig & {
|
|
26
|
+
apiVersion?: string | null;
|
|
27
|
+
payload?: T | null;
|
|
28
|
+
};
|
|
15
29
|
/**
|
|
16
30
|
* Helper type for merging new API into existing contract during chaining
|
|
17
31
|
*/
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BatchWriteItemCommand, DeleteItemCommand, DynamoDBClient, GetItemCommand, PutItemCommand, QueryCommand, } from "@aws-sdk/client-dynamodb";
|
|
2
|
-
import { getCrypto
|
|
2
|
+
import { getCrypto } from "../shared/node-polyfills.js";
|
|
3
|
+
import { brotliCompressText, brotliDecompressText } from "./LambderDdbCompression.js";
|
|
3
4
|
import { LRUCache } from "lru-cache";
|
|
4
5
|
const DEFAULT_TTL_SECONDS = 365 * 24 * 60 * 60;
|
|
5
6
|
const DEFAULT_CHUNK_BYTES = 350 * 1024;
|
|
@@ -12,46 +13,14 @@ const BATCH_WRITE_LIMIT = 25;
|
|
|
12
13
|
const MAX_BATCH_RETRIES = 8;
|
|
13
14
|
// Node builtins are loaded lazily through node-polyfills so this module can
|
|
14
15
|
// sit in a frontend bundle's import graph (via the package root) without
|
|
15
|
-
// breaking; using the cache at runtime still requires Node.
|
|
16
|
-
|
|
17
|
-
const zlib = await getZlib();
|
|
18
|
-
if (!zlib)
|
|
19
|
-
throw new Error("LambderDdbCache requires a Node.js environment.");
|
|
20
|
-
return zlib;
|
|
21
|
-
};
|
|
16
|
+
// breaking; using the cache at runtime still requires Node. Brotli helpers
|
|
17
|
+
// are shared with LambderDdbIdempotency via ./LambderDdbCompression.js.
|
|
22
18
|
const requireCrypto = async () => {
|
|
23
19
|
const crypto = await getCrypto();
|
|
24
20
|
if (!crypto)
|
|
25
21
|
throw new Error("LambderDdbCache requires a Node.js environment.");
|
|
26
22
|
return crypto;
|
|
27
23
|
};
|
|
28
|
-
const compress = async (input, quality) => {
|
|
29
|
-
const zlib = await requireZlib();
|
|
30
|
-
return new Promise((resolve, reject) => {
|
|
31
|
-
zlib.brotliCompress(input, {
|
|
32
|
-
params: {
|
|
33
|
-
[zlib.constants.BROTLI_PARAM_QUALITY]: quality,
|
|
34
|
-
[zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_TEXT,
|
|
35
|
-
},
|
|
36
|
-
}, (error, output) => {
|
|
37
|
-
if (error)
|
|
38
|
-
reject(error);
|
|
39
|
-
else
|
|
40
|
-
resolve(output);
|
|
41
|
-
});
|
|
42
|
-
});
|
|
43
|
-
};
|
|
44
|
-
const decompress = async (input, maxOutputLength) => {
|
|
45
|
-
const zlib = await requireZlib();
|
|
46
|
-
return new Promise((resolve, reject) => {
|
|
47
|
-
zlib.brotliDecompress(input, { maxOutputLength }, (error, output) => {
|
|
48
|
-
if (error)
|
|
49
|
-
reject(error);
|
|
50
|
-
else
|
|
51
|
-
resolve(output);
|
|
52
|
-
});
|
|
53
|
-
});
|
|
54
|
-
};
|
|
55
24
|
const sha256 = async (value) => {
|
|
56
25
|
const crypto = await requireCrypto();
|
|
57
26
|
return crypto.createHash("sha256").update(value).digest("hex");
|
|
@@ -127,7 +96,7 @@ export class LambderDdbCache {
|
|
|
127
96
|
const nowSeconds = this.nowSeconds();
|
|
128
97
|
if (cached && cached.expiresAt > nowSeconds) {
|
|
129
98
|
try {
|
|
130
|
-
const output = await
|
|
99
|
+
const output = await brotliDecompressText(cached.compressed, this.maxValueBytes);
|
|
131
100
|
if (output.length === cached.uncompressedBytes) {
|
|
132
101
|
return JSON.parse(output.toString("utf8"));
|
|
133
102
|
}
|
|
@@ -150,7 +119,7 @@ export class LambderDdbCache {
|
|
|
150
119
|
if (await sha256(compressed) !== manifest.checksum) {
|
|
151
120
|
throw new Error("compressed checksum does not match manifest");
|
|
152
121
|
}
|
|
153
|
-
const output = await
|
|
122
|
+
const output = await brotliDecompressText(compressed, this.maxValueBytes);
|
|
154
123
|
if (output.length !== manifest.uncompressedBytes) {
|
|
155
124
|
throw new Error("uncompressed byte length does not match manifest");
|
|
156
125
|
}
|
|
@@ -186,7 +155,7 @@ export class LambderDdbCache {
|
|
|
186
155
|
if (input.length > this.maxValueBytes) {
|
|
187
156
|
throw new Error(`Cache value exceeds maxValueBytes (${input.length} > ${this.maxValueBytes})`);
|
|
188
157
|
}
|
|
189
|
-
const compressed = await
|
|
158
|
+
const compressed = await brotliCompressText(input, this.compressionQuality);
|
|
190
159
|
if (compressed.length > this.maxValueBytes) {
|
|
191
160
|
throw new Error(`Compressed cache value exceeds maxValueBytes (${compressed.length} > ${this.maxValueBytes})`);
|
|
192
161
|
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare const brotliCompressText: (input: Buffer, quality: number) => Promise<Buffer>;
|
|
2
|
+
/** maxOutputLength bounds decompression so a corrupt record cannot balloon memory. */
|
|
3
|
+
export declare const brotliDecompressText: (input: Buffer, maxOutputLength: number) => Promise<Buffer>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { getZlib } from "../shared/node-polyfills.js";
|
|
2
|
+
// Brotli compression shared by the DynamoDB-backed stores (LambderDdbCache,
|
|
3
|
+
// LambderDdbIdempotency). Values they persist are text (JSON), so TEXT mode;
|
|
4
|
+
// zlib is loaded lazily through node-polyfills so these modules can sit in a
|
|
5
|
+
// frontend bundle's import graph (via the package root) without breaking.
|
|
6
|
+
const requireZlib = async () => {
|
|
7
|
+
const zlib = await getZlib();
|
|
8
|
+
if (!zlib)
|
|
9
|
+
throw new Error("Lambder DDB stores require a Node.js environment.");
|
|
10
|
+
return zlib;
|
|
11
|
+
};
|
|
12
|
+
export const brotliCompressText = async (input, quality) => {
|
|
13
|
+
const zlib = await requireZlib();
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
zlib.brotliCompress(input, {
|
|
16
|
+
params: {
|
|
17
|
+
[zlib.constants.BROTLI_PARAM_QUALITY]: quality,
|
|
18
|
+
[zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_TEXT,
|
|
19
|
+
},
|
|
20
|
+
}, (error, output) => {
|
|
21
|
+
if (error)
|
|
22
|
+
reject(error);
|
|
23
|
+
else
|
|
24
|
+
resolve(output);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
/** maxOutputLength bounds decompression so a corrupt record cannot balloon memory. */
|
|
29
|
+
export const brotliDecompressText = async (input, maxOutputLength) => {
|
|
30
|
+
const zlib = await requireZlib();
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
zlib.brotliDecompress(input, { maxOutputLength }, (error, output) => {
|
|
33
|
+
if (error)
|
|
34
|
+
reject(error);
|
|
35
|
+
else
|
|
36
|
+
resolve(output);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
};
|
|
@@ -4,19 +4,24 @@ export interface LambderDdbIdempotencyOptions {
|
|
|
4
4
|
region?: string;
|
|
5
5
|
/** Partition key prefix, keeps records separated from other systems in a shared table. Default: "IDEM". */
|
|
6
6
|
keyPrefix?: string;
|
|
7
|
+
/** Brotli quality (0-11) for stored bodies, like LambderDdbCache. Default: 5. */
|
|
8
|
+
compressionQuality?: number;
|
|
7
9
|
client?: DynamoDBClient;
|
|
8
10
|
}
|
|
11
|
+
export type LambderIdempotencyDoneRecord = {
|
|
12
|
+
statusCode: number;
|
|
13
|
+
/** Response headers stored with the record (normalized multi-value map). */
|
|
14
|
+
headers: Record<string, string[]>;
|
|
15
|
+
body: string;
|
|
16
|
+
};
|
|
9
17
|
export type LambderIdempotencyBeginResult = {
|
|
10
18
|
state: "new";
|
|
11
19
|
ownerToken: string;
|
|
12
20
|
} | {
|
|
13
21
|
state: "pending";
|
|
14
|
-
} | {
|
|
22
|
+
} | ({
|
|
15
23
|
state: "done";
|
|
16
|
-
|
|
17
|
-
contentType: string | null;
|
|
18
|
-
body: string;
|
|
19
|
-
};
|
|
24
|
+
} & LambderIdempotencyDoneRecord);
|
|
20
25
|
/**
|
|
21
26
|
* DynamoDB-backed idempotency records: one item per (identity, api, key)
|
|
22
27
|
* scope, claimed atomically with a conditional put. The first request claims
|
|
@@ -30,6 +35,11 @@ export type LambderIdempotencyBeginResult = {
|
|
|
30
35
|
* and loses the scope to a retry can no longer overwrite or delete the
|
|
31
36
|
* retry's claim (both settle calls become silent no-ops instead).
|
|
32
37
|
*
|
|
38
|
+
* Stored bodies of 1KB or more are Brotli-compressed (same scheme as
|
|
39
|
+
* LambderDdbCache): the bodies are JSON envelopes that typically shrink
|
|
40
|
+
* 5-10x, which cuts DynamoDB write units and lets large responses fit the
|
|
41
|
+
* item budget instead of skipping replay storage.
|
|
42
|
+
*
|
|
33
43
|
* Table shape: string hash key `pk`, string range key `sk`, TTL on
|
|
34
44
|
* `expiresAt`. Items are prefixed `IDEM#` by default, so the table can be
|
|
35
45
|
* shared with LambderDdbRateLimiter (`RL#`) and LambderDdbCache (`CACHE#`)
|
|
@@ -38,9 +48,21 @@ export type LambderIdempotencyBeginResult = {
|
|
|
38
48
|
export declare class LambderDdbIdempotency {
|
|
39
49
|
readonly tableName: string;
|
|
40
50
|
readonly keyPrefix: string;
|
|
51
|
+
private readonly compressionQuality;
|
|
41
52
|
private readonly client;
|
|
42
53
|
constructor(options: LambderDdbIdempotencyOptions);
|
|
43
54
|
private itemKey;
|
|
55
|
+
/** Parse a stored item's response headers. */
|
|
56
|
+
private static readItemHeaders;
|
|
57
|
+
/** A stored item's response body: plain (`body`) or Brotli (`bodyBr` + `bodyBytes`). */
|
|
58
|
+
private static readItemBody;
|
|
59
|
+
/**
|
|
60
|
+
* Read the scope without claiming it: the stored response when a
|
|
61
|
+
* completed, unexpired record exists, null otherwise (absent, pending, or
|
|
62
|
+
* expired). Eventually-consistent read: a miss here only means the caller
|
|
63
|
+
* proceeds to begin(), whose read is authoritative.
|
|
64
|
+
*/
|
|
65
|
+
peek(scopeKey: string): Promise<LambderIdempotencyDoneRecord | null>;
|
|
44
66
|
/**
|
|
45
67
|
* Claim the scope. "new" means this request now owns it (proven by the
|
|
46
68
|
* returned ownerToken) and must call complete() or abandon(); "pending"
|
|
@@ -51,17 +73,24 @@ export declare class LambderDdbIdempotency {
|
|
|
51
73
|
pendingTtlSeconds: number;
|
|
52
74
|
}): Promise<LambderIdempotencyBeginResult>;
|
|
53
75
|
/**
|
|
54
|
-
* Store the response for replays, overwriting the pending claim.
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* the
|
|
76
|
+
* Store the response for replays, overwriting the pending claim. Bodies
|
|
77
|
+
* of COMPRESS_MIN_BYTES or more are stored Brotli-compressed (they are
|
|
78
|
+
* JSON envelopes, which typically shrink 5-10x), cutting DynamoDB write
|
|
79
|
+
* units and letting large responses fit the item budget; smaller bodies
|
|
80
|
+
* stay plain. Returns:
|
|
81
|
+
*
|
|
82
|
+
* - "stored": the record is in place and will replay.
|
|
83
|
+
* - "too-large": even compressed, the body exceeds the item budget;
|
|
84
|
+
* nothing was written and the caller should release the claim.
|
|
85
|
+
* - "lost": the ownerToken no longer matches, i.e. the claim expired and
|
|
86
|
+
* a retry took the scope over; nothing was written.
|
|
58
87
|
*/
|
|
59
|
-
complete(scopeKey: string, ownerToken: string, { statusCode,
|
|
88
|
+
complete(scopeKey: string, ownerToken: string, { statusCode, headers, body, ttlSeconds }: {
|
|
60
89
|
statusCode: number;
|
|
61
|
-
|
|
90
|
+
headers: Record<string, string[]>;
|
|
62
91
|
body: string;
|
|
63
92
|
ttlSeconds: number;
|
|
64
|
-
}): Promise<
|
|
93
|
+
}): Promise<"stored" | "too-large" | "lost">;
|
|
65
94
|
/**
|
|
66
95
|
* Release the claim without storing a response (crash, uncacheable
|
|
67
96
|
* response), so a retry can execute. Conditional on still holding the
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import crypto from "crypto";
|
|
2
2
|
import { DynamoDBClient, PutItemCommand, GetItemCommand, DeleteItemCommand, } from "@aws-sdk/client-dynamodb";
|
|
3
|
+
import { brotliCompressText, brotliDecompressText } from "./LambderDdbCompression.js";
|
|
4
|
+
/** Bodies at or above this size are stored Brotli-compressed; smaller ones stay plain. */
|
|
5
|
+
const COMPRESS_MIN_BYTES = 1024;
|
|
6
|
+
/**
|
|
7
|
+
* Stored-body budget inside DynamoDB's 400KB item limit (headers, keys and
|
|
8
|
+
* attributes need headroom). Applies to the bytes actually stored, so a
|
|
9
|
+
* large compressible response (JSON usually shrinks 5-10x) still replays.
|
|
10
|
+
*/
|
|
11
|
+
const MAX_STORED_BODY_BYTES = 350_000;
|
|
3
12
|
/**
|
|
4
13
|
* DynamoDB-backed idempotency records: one item per (identity, api, key)
|
|
5
14
|
* scope, claimed atomically with a conditional put. The first request claims
|
|
@@ -13,6 +22,11 @@ import { DynamoDBClient, PutItemCommand, GetItemCommand, DeleteItemCommand, } fr
|
|
|
13
22
|
* and loses the scope to a retry can no longer overwrite or delete the
|
|
14
23
|
* retry's claim (both settle calls become silent no-ops instead).
|
|
15
24
|
*
|
|
25
|
+
* Stored bodies of 1KB or more are Brotli-compressed (same scheme as
|
|
26
|
+
* LambderDdbCache): the bodies are JSON envelopes that typically shrink
|
|
27
|
+
* 5-10x, which cuts DynamoDB write units and lets large responses fit the
|
|
28
|
+
* item budget instead of skipping replay storage.
|
|
29
|
+
*
|
|
16
30
|
* Table shape: string hash key `pk`, string range key `sk`, TTL on
|
|
17
31
|
* `expiresAt`. Items are prefixed `IDEM#` by default, so the table can be
|
|
18
32
|
* shared with LambderDdbRateLimiter (`RL#`) and LambderDdbCache (`CACHE#`)
|
|
@@ -21,17 +35,73 @@ import { DynamoDBClient, PutItemCommand, GetItemCommand, DeleteItemCommand, } fr
|
|
|
21
35
|
export class LambderDdbIdempotency {
|
|
22
36
|
tableName;
|
|
23
37
|
keyPrefix;
|
|
38
|
+
compressionQuality;
|
|
24
39
|
client;
|
|
25
40
|
constructor(options) {
|
|
26
41
|
if (!options.tableName.trim())
|
|
27
42
|
throw new Error("tableName is required");
|
|
28
43
|
this.tableName = options.tableName;
|
|
29
44
|
this.keyPrefix = options.keyPrefix ?? "IDEM";
|
|
45
|
+
this.compressionQuality = options.compressionQuality ?? 5;
|
|
46
|
+
if (!Number.isInteger(this.compressionQuality) || this.compressionQuality < 0 || this.compressionQuality > 11) {
|
|
47
|
+
throw new Error("compressionQuality must be an integer from 0 to 11");
|
|
48
|
+
}
|
|
30
49
|
this.client = options.client ?? new DynamoDBClient(options.region ? { region: options.region } : {});
|
|
31
50
|
}
|
|
32
51
|
itemKey(scopeKey) {
|
|
33
52
|
return { pk: { S: `${this.keyPrefix}#${scopeKey}` }, sk: { S: "idem" } };
|
|
34
53
|
}
|
|
54
|
+
/** Parse a stored item's response headers. */
|
|
55
|
+
static readItemHeaders(item) {
|
|
56
|
+
const raw = item.headersJson?.S;
|
|
57
|
+
if (!raw)
|
|
58
|
+
return {};
|
|
59
|
+
try {
|
|
60
|
+
const parsed = JSON.parse(raw);
|
|
61
|
+
if (parsed && typeof parsed === "object")
|
|
62
|
+
return parsed;
|
|
63
|
+
}
|
|
64
|
+
catch { /* corrupt record: replay with no headers rather than fail */ }
|
|
65
|
+
return {};
|
|
66
|
+
}
|
|
67
|
+
/** A stored item's response body: plain (`body`) or Brotli (`bodyBr` + `bodyBytes`). */
|
|
68
|
+
static async readItemBody(item) {
|
|
69
|
+
const compressed = item.bodyBr?.B;
|
|
70
|
+
if (compressed) {
|
|
71
|
+
const declaredBytes = Number(item.bodyBytes?.N ?? 0);
|
|
72
|
+
if (!declaredBytes)
|
|
73
|
+
throw new Error("LambderDdbIdempotency: compressed record is missing bodyBytes.");
|
|
74
|
+
const output = await brotliDecompressText(Buffer.from(compressed), declaredBytes);
|
|
75
|
+
if (output.length !== declaredBytes) {
|
|
76
|
+
throw new Error("LambderDdbIdempotency: stored body length does not match its record.");
|
|
77
|
+
}
|
|
78
|
+
return output.toString("utf8");
|
|
79
|
+
}
|
|
80
|
+
return item.body?.S ?? "";
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Read the scope without claiming it: the stored response when a
|
|
84
|
+
* completed, unexpired record exists, null otherwise (absent, pending, or
|
|
85
|
+
* expired). Eventually-consistent read: a miss here only means the caller
|
|
86
|
+
* proceeds to begin(), whose read is authoritative.
|
|
87
|
+
*/
|
|
88
|
+
async peek(scopeKey) {
|
|
89
|
+
const existing = await this.client.send(new GetItemCommand({
|
|
90
|
+
TableName: this.tableName,
|
|
91
|
+
Key: this.itemKey(scopeKey),
|
|
92
|
+
}));
|
|
93
|
+
const item = existing.Item;
|
|
94
|
+
if (!item || item.state?.S !== "done")
|
|
95
|
+
return null;
|
|
96
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
97
|
+
if (Number(item.expiresAt?.N ?? 0) <= nowSeconds)
|
|
98
|
+
return null;
|
|
99
|
+
return {
|
|
100
|
+
statusCode: Number(item.statusCode?.N ?? 200),
|
|
101
|
+
headers: LambderDdbIdempotency.readItemHeaders(item),
|
|
102
|
+
body: await LambderDdbIdempotency.readItemBody(item),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
35
105
|
/**
|
|
36
106
|
* Claim the scope. "new" means this request now owns it (proven by the
|
|
37
107
|
* returned ownerToken) and must call complete() or abandon(); "pending"
|
|
@@ -72,20 +142,39 @@ export class LambderDdbIdempotency {
|
|
|
72
142
|
return {
|
|
73
143
|
state: "done",
|
|
74
144
|
statusCode: Number(item.statusCode?.N ?? 200),
|
|
75
|
-
|
|
76
|
-
body: item
|
|
145
|
+
headers: LambderDdbIdempotency.readItemHeaders(item),
|
|
146
|
+
body: await LambderDdbIdempotency.readItemBody(item),
|
|
77
147
|
};
|
|
78
148
|
}
|
|
79
149
|
return { state: "pending" };
|
|
80
150
|
}
|
|
81
151
|
/**
|
|
82
|
-
* Store the response for replays, overwriting the pending claim.
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* the
|
|
152
|
+
* Store the response for replays, overwriting the pending claim. Bodies
|
|
153
|
+
* of COMPRESS_MIN_BYTES or more are stored Brotli-compressed (they are
|
|
154
|
+
* JSON envelopes, which typically shrink 5-10x), cutting DynamoDB write
|
|
155
|
+
* units and letting large responses fit the item budget; smaller bodies
|
|
156
|
+
* stay plain. Returns:
|
|
157
|
+
*
|
|
158
|
+
* - "stored": the record is in place and will replay.
|
|
159
|
+
* - "too-large": even compressed, the body exceeds the item budget;
|
|
160
|
+
* nothing was written and the caller should release the claim.
|
|
161
|
+
* - "lost": the ownerToken no longer matches, i.e. the claim expired and
|
|
162
|
+
* a retry took the scope over; nothing was written.
|
|
86
163
|
*/
|
|
87
|
-
async complete(scopeKey, ownerToken, { statusCode,
|
|
164
|
+
async complete(scopeKey, ownerToken, { statusCode, headers, body, ttlSeconds }) {
|
|
88
165
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
166
|
+
const rawBody = Buffer.from(body, "utf8");
|
|
167
|
+
let bodyAttributes;
|
|
168
|
+
if (rawBody.byteLength >= COMPRESS_MIN_BYTES) {
|
|
169
|
+
const compressed = await brotliCompressText(rawBody, this.compressionQuality);
|
|
170
|
+
if (compressed.byteLength > MAX_STORED_BODY_BYTES)
|
|
171
|
+
return "too-large";
|
|
172
|
+
// bodyBytes bounds and verifies decompression on read.
|
|
173
|
+
bodyAttributes = { bodyBr: { B: compressed }, bodyBytes: { N: String(rawBody.byteLength) } };
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
bodyAttributes = { body: { S: body } };
|
|
177
|
+
}
|
|
89
178
|
try {
|
|
90
179
|
await this.client.send(new PutItemCommand({
|
|
91
180
|
TableName: this.tableName,
|
|
@@ -94,19 +183,19 @@ export class LambderDdbIdempotency {
|
|
|
94
183
|
state: { S: "done" },
|
|
95
184
|
ownerToken: { S: ownerToken },
|
|
96
185
|
statusCode: { N: String(statusCode) },
|
|
97
|
-
|
|
98
|
-
|
|
186
|
+
headersJson: { S: JSON.stringify(headers) },
|
|
187
|
+
...bodyAttributes,
|
|
99
188
|
expiresAt: { N: String(nowSeconds + ttlSeconds) },
|
|
100
189
|
},
|
|
101
190
|
ConditionExpression: "ownerToken = :owner",
|
|
102
191
|
ExpressionAttributeValues: { ":owner": { S: ownerToken } },
|
|
103
192
|
}));
|
|
104
|
-
return
|
|
193
|
+
return "stored";
|
|
105
194
|
}
|
|
106
195
|
catch (error) {
|
|
107
196
|
if (error.name !== "ConditionalCheckFailedException")
|
|
108
197
|
throw error;
|
|
109
|
-
return
|
|
198
|
+
return "lost";
|
|
110
199
|
}
|
|
111
200
|
}
|
|
112
201
|
/**
|
|
@@ -81,8 +81,12 @@ export class LambderDdbRateLimiter {
|
|
|
81
81
|
catch (error) {
|
|
82
82
|
if (error.name === "ConditionalCheckFailedException")
|
|
83
83
|
return true;
|
|
84
|
-
if (this.failOpen)
|
|
84
|
+
if (this.failOpen) {
|
|
85
|
+
// Failing open swallows the error from the caller's view, so
|
|
86
|
+
// keep the infra failure visible in the logs.
|
|
87
|
+
console.error(`LambderDdbRateLimiter: DynamoDB error while counting "${trackerKey}", allowing the request (failOpen).`, error);
|
|
85
88
|
return false;
|
|
89
|
+
}
|
|
86
90
|
throw error;
|
|
87
91
|
}
|
|
88
92
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Testing entry point (`import ... from "lambder/testing"`).
|
|
3
|
+
*
|
|
4
|
+
* Mock tooling that neither the server nor the production client bundle
|
|
5
|
+
* should carry: the MSW adapter that serves an app's typed API contract
|
|
6
|
+
* from in-browser mock handlers during development and tests.
|
|
7
|
+
*/
|
|
8
|
+
export { default as LambderMSW } from "./client/LambderMSW.js";
|
|
9
|
+
export type { LambderMswModule } from "./client/LambderMSW.js";
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Testing entry point (`import ... from "lambder/testing"`).
|
|
3
|
+
*
|
|
4
|
+
* Mock tooling that neither the server nor the production client bundle
|
|
5
|
+
* should carry: the MSW adapter that serves an app's typed API contract
|
|
6
|
+
* from in-browser mock handlers during development and tests.
|
|
7
|
+
*/
|
|
8
|
+
export { default as LambderMSW } from "./client/LambderMSW.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lambder",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.1",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -9,6 +9,24 @@
|
|
|
9
9
|
".": {
|
|
10
10
|
"types": "./dist/index.d.ts",
|
|
11
11
|
"default": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./client": {
|
|
14
|
+
"types": "./dist/client.d.ts",
|
|
15
|
+
"default": "./dist/client.js"
|
|
16
|
+
},
|
|
17
|
+
"./testing": {
|
|
18
|
+
"types": "./dist/testing.d.ts",
|
|
19
|
+
"default": "./dist/testing.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"typesVersions": {
|
|
23
|
+
"*": {
|
|
24
|
+
"client": [
|
|
25
|
+
"./dist/client.d.ts"
|
|
26
|
+
],
|
|
27
|
+
"testing": [
|
|
28
|
+
"./dist/testing.d.ts"
|
|
29
|
+
]
|
|
12
30
|
}
|
|
13
31
|
},
|
|
14
32
|
"browser": {
|