lambder 3.0.0 → 3.1.2

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 CHANGED
@@ -428,6 +428,28 @@ Responses are finalized once at the end of the request: automatic gzip (when the
428
428
 
429
429
  **Die Methods**: `res.die.*` - Builds the response and throws it, immediately halting the request at any call depth (handlers, hooks, nested helper functions). Plain `throw res.html(...)` works the same way.
430
430
 
431
+ ### DynamoDB Cache (LambderDdbCache)
432
+
433
+ Standalone, persistent JSON cache backed by a DynamoDB table (`pk`/`sk` keys + `expiresAt` TTL attribute, same shape as the session table). Values are Brotli-compressed; small values are stored inline in a manifest item, large values are split into versioned binary chunks written before the manifest, so readers only ever see complete versions. Includes an in-memory LRU layer for warm invocations, single-flight deduplication, a DynamoDB lease so only one Lambda fills a missing key, and fail-open semantics (cache infrastructure errors fall back to the loader; loader errors propagate).
434
+
435
+ ```typescript
436
+ import { LambderDdbCache } from "lambder";
437
+
438
+ const cache = new LambderDdbCache({
439
+ tableName: "myapp-cache",
440
+ region: "us-east-1",
441
+ namespace: "geo", // isolates keys per domain
442
+ defaultTtlSeconds: 24 * 3600,
443
+ });
444
+
445
+ const city = await cache.getOrSet(`city:${slug}`, async () => fetchCityFromDb(slug), {
446
+ ttlSeconds: 7 * 24 * 3600,
447
+ });
448
+ // Also: cache.get(key), cache.set(key, value, { ttlSeconds }), cache.has(key), cache.delete(key)
449
+ ```
450
+
451
+ Required IAM actions on the table: `dynamodb:GetItem`, `PutItem`, `DeleteItem`, `Query`, `BatchWriteItem`. Server-only (uses AWS SDK + zlib).
452
+
431
453
  ## Frontend Usage with LambderCaller
432
454
 
433
455
  LambderCaller is a frontend companion library for Lambder (only 2kb compressed) designed to simplify making type-safe API requests to your Lambder backend.
@@ -0,0 +1,65 @@
1
+ import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
2
+ export interface LambderDdbCacheOptions {
3
+ tableName: string;
4
+ region?: string;
5
+ namespace?: string;
6
+ defaultTtlSeconds?: number;
7
+ chunkBytes?: number;
8
+ compressionQuality?: number;
9
+ maxValueBytes?: number;
10
+ memoryMaxBytes?: number;
11
+ client?: DynamoDBClient;
12
+ }
13
+ export interface LambderDdbCacheSetOptions {
14
+ ttlSeconds?: number;
15
+ }
16
+ export interface LambderDdbCacheGetOrSetOptions extends LambderDdbCacheSetOptions {
17
+ leaseSeconds?: number;
18
+ waitForFillMs?: number;
19
+ }
20
+ /**
21
+ * Persistent JSON cache backed by DynamoDB.
22
+ *
23
+ * Values are Brotli-compressed. Values within the safe DynamoDB item budget are
24
+ * stored directly in the manifest for a single-request read; larger values are
25
+ * split into versioned binary chunks. A manifest is written only after every
26
+ * chunk succeeds, so readers see either the previous complete version or the
27
+ * new complete version. DynamoDB TTL is cleanup only; every read also checks
28
+ * expiresAt because TTL deletion can lag.
29
+ */
30
+ export declare class LambderDdbCache {
31
+ readonly tableName: string;
32
+ readonly namespace: string;
33
+ private readonly client;
34
+ private readonly defaultTtlSeconds;
35
+ private readonly chunkBytes;
36
+ private readonly compressionQuality;
37
+ private readonly maxValueBytes;
38
+ private readonly memory;
39
+ private readonly inFlight;
40
+ constructor(options: LambderDdbCacheOptions);
41
+ get<T>(key: string): Promise<T | undefined>;
42
+ has(key: string): Promise<boolean>;
43
+ set<T>(key: string, value: T, options?: LambderDdbCacheSetOptions): Promise<void>;
44
+ delete(key: string): Promise<boolean>;
45
+ getOrSet<T>(key: string, factory: () => Promise<T>, options?: LambderDdbCacheGetOrSetOptions): Promise<T>;
46
+ /**
47
+ * Cache infrastructure is best-effort for getOrSet: read, lease, or write
48
+ * failures return the loader value. Loader failures still propagate and the
49
+ * loader is never repeated after it has completed successfully.
50
+ */
51
+ private getOrSetFailOpen;
52
+ private fill;
53
+ private acquireLease;
54
+ private releaseLease;
55
+ private readManifest;
56
+ private readChunks;
57
+ private invalidateManifest;
58
+ private batchWrite;
59
+ private remember;
60
+ private normalizeKey;
61
+ private partitionKey;
62
+ private chunkSortKey;
63
+ private nowSeconds;
64
+ private isConditionalFailure;
65
+ }
@@ -0,0 +1,506 @@
1
+ import { BatchWriteItemCommand, DeleteItemCommand, DynamoDBClient, GetItemCommand, PutItemCommand, QueryCommand, } from "@aws-sdk/client-dynamodb";
2
+ import { getCrypto, getZlib } from "./node-polyfills.js";
3
+ import { LRUCache } from "lru-cache";
4
+ const DEFAULT_TTL_SECONDS = 365 * 24 * 60 * 60;
5
+ const DEFAULT_CHUNK_BYTES = 350 * 1024;
6
+ const MAX_SAFE_CHUNK_BYTES = 380 * 1024;
7
+ const DEFAULT_MAX_VALUE_BYTES = 32 * 1024 * 1024;
8
+ const DEFAULT_MEMORY_BYTES = 16 * 1024 * 1024;
9
+ const META_SORT_KEY = "meta";
10
+ const LOCK_SORT_KEY = "lock";
11
+ const BATCH_WRITE_LIMIT = 25;
12
+ const MAX_BATCH_RETRIES = 8;
13
+ // Node builtins are loaded lazily through node-polyfills so this module can
14
+ // sit in a frontend bundle's import graph (via the package root) without
15
+ // breaking; using the cache at runtime still requires Node.
16
+ const requireZlib = async () => {
17
+ const zlib = await getZlib();
18
+ if (!zlib)
19
+ throw new Error("LambderDdbCache requires a Node.js environment.");
20
+ return zlib;
21
+ };
22
+ const requireCrypto = async () => {
23
+ const crypto = await getCrypto();
24
+ if (!crypto)
25
+ throw new Error("LambderDdbCache requires a Node.js environment.");
26
+ return crypto;
27
+ };
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
+ const sha256 = async (value) => {
56
+ const crypto = await requireCrypto();
57
+ return crypto.createHash("sha256").update(value).digest("hex");
58
+ };
59
+ const randomUUID = async () => {
60
+ const crypto = await requireCrypto();
61
+ return crypto.randomUUID();
62
+ };
63
+ const positiveInteger = (value, name) => {
64
+ if (!Number.isSafeInteger(value) || value <= 0) {
65
+ throw new Error(`${name} must be a positive safe integer`);
66
+ }
67
+ return value;
68
+ };
69
+ const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
70
+ /**
71
+ * Persistent JSON cache backed by DynamoDB.
72
+ *
73
+ * Values are Brotli-compressed. Values within the safe DynamoDB item budget are
74
+ * stored directly in the manifest for a single-request read; larger values are
75
+ * split into versioned binary chunks. A manifest is written only after every
76
+ * chunk succeeds, so readers see either the previous complete version or the
77
+ * new complete version. DynamoDB TTL is cleanup only; every read also checks
78
+ * expiresAt because TTL deletion can lag.
79
+ */
80
+ export class LambderDdbCache {
81
+ tableName;
82
+ namespace;
83
+ client;
84
+ defaultTtlSeconds;
85
+ chunkBytes;
86
+ compressionQuality;
87
+ maxValueBytes;
88
+ memory;
89
+ inFlight = new Map();
90
+ constructor(options) {
91
+ if (!options.tableName.trim())
92
+ throw new Error("tableName is required");
93
+ this.tableName = options.tableName;
94
+ this.namespace = options.namespace?.trim() || "default";
95
+ if (Buffer.byteLength(this.namespace, "utf8") > 128) {
96
+ throw new Error("namespace must be at most 128 UTF-8 bytes");
97
+ }
98
+ this.defaultTtlSeconds = positiveInteger(options.defaultTtlSeconds ?? DEFAULT_TTL_SECONDS, "defaultTtlSeconds");
99
+ this.chunkBytes = positiveInteger(options.chunkBytes ?? DEFAULT_CHUNK_BYTES, "chunkBytes");
100
+ if (this.chunkBytes > MAX_SAFE_CHUNK_BYTES) {
101
+ throw new Error(`chunkBytes must not exceed ${MAX_SAFE_CHUNK_BYTES}`);
102
+ }
103
+ this.compressionQuality = options.compressionQuality ?? 5;
104
+ if (!Number.isInteger(this.compressionQuality) || this.compressionQuality < 0 || this.compressionQuality > 11) {
105
+ throw new Error("compressionQuality must be an integer from 0 to 11");
106
+ }
107
+ this.maxValueBytes = positiveInteger(options.maxValueBytes ?? DEFAULT_MAX_VALUE_BYTES, "maxValueBytes");
108
+ const memoryMaxBytes = options.memoryMaxBytes ?? DEFAULT_MEMORY_BYTES;
109
+ this.memory = memoryMaxBytes === 0
110
+ ? null
111
+ : new LRUCache({
112
+ maxSize: positiveInteger(memoryMaxBytes, "memoryMaxBytes"),
113
+ sizeCalculation: (entry) => entry.compressed.length,
114
+ });
115
+ this.client = options.client ?? new DynamoDBClient({ region: options.region ?? "us-east-1" });
116
+ }
117
+ async get(key) {
118
+ const normalizedKey = this.normalizeKey(key);
119
+ const cached = this.memory?.get(normalizedKey);
120
+ const nowSeconds = this.nowSeconds();
121
+ if (cached && cached.expiresAt > nowSeconds) {
122
+ try {
123
+ const output = await decompress(cached.compressed, this.maxValueBytes);
124
+ if (output.length === cached.uncompressedBytes) {
125
+ return JSON.parse(output.toString("utf8"));
126
+ }
127
+ }
128
+ catch {
129
+ // Fall through to DynamoDB; the in-memory copy is disposable.
130
+ }
131
+ }
132
+ if (cached)
133
+ this.memory?.delete(normalizedKey);
134
+ const pk = await this.partitionKey(normalizedKey);
135
+ const manifest = await this.readManifest(pk);
136
+ if (!manifest || manifest.expiresAt <= nowSeconds)
137
+ return undefined;
138
+ try {
139
+ const compressed = manifest.inlineData ?? await this.readChunks(pk, manifest);
140
+ if (compressed.length !== manifest.compressedBytes) {
141
+ throw new Error("compressed byte length does not match manifest");
142
+ }
143
+ if (await sha256(compressed) !== manifest.checksum) {
144
+ throw new Error("compressed checksum does not match manifest");
145
+ }
146
+ const output = await decompress(compressed, this.maxValueBytes);
147
+ if (output.length !== manifest.uncompressedBytes) {
148
+ throw new Error("uncompressed byte length does not match manifest");
149
+ }
150
+ const json = output.toString("utf8");
151
+ const parsed = JSON.parse(json);
152
+ this.remember(normalizedKey, compressed, output.length, manifest.expiresAt);
153
+ return parsed;
154
+ }
155
+ catch (error) {
156
+ await this.invalidateManifest(pk, manifest.version);
157
+ console.warn(`Ignoring corrupt DynamoDB cache entry in ${this.namespace}`, error);
158
+ return undefined;
159
+ }
160
+ }
161
+ async has(key) {
162
+ const normalizedKey = this.normalizeKey(key);
163
+ const cached = this.memory?.get(normalizedKey);
164
+ const nowSeconds = this.nowSeconds();
165
+ if (cached?.expiresAt && cached.expiresAt > nowSeconds)
166
+ return true;
167
+ if (cached)
168
+ this.memory?.delete(normalizedKey);
169
+ const manifest = await this.readManifest(await this.partitionKey(normalizedKey));
170
+ return !!manifest && manifest.expiresAt > nowSeconds;
171
+ }
172
+ async set(key, value, options = {}) {
173
+ const normalizedKey = this.normalizeKey(key);
174
+ const ttlSeconds = positiveInteger(options.ttlSeconds ?? this.defaultTtlSeconds, "ttlSeconds");
175
+ const json = JSON.stringify(value);
176
+ if (json === undefined)
177
+ throw new Error("Cache value must be JSON-serializable");
178
+ const input = Buffer.from(json, "utf8");
179
+ if (input.length > this.maxValueBytes) {
180
+ throw new Error(`Cache value exceeds maxValueBytes (${input.length} > ${this.maxValueBytes})`);
181
+ }
182
+ const compressed = await compress(input, this.compressionQuality);
183
+ if (compressed.length > this.maxValueBytes) {
184
+ throw new Error(`Compressed cache value exceeds maxValueBytes (${compressed.length} > ${this.maxValueBytes})`);
185
+ }
186
+ const pk = await this.partitionKey(normalizedKey);
187
+ const version = `${Date.now().toString(36)}-${await randomUUID()}`;
188
+ const expiresAt = this.nowSeconds() + ttlSeconds;
189
+ const chunks = [];
190
+ const inline = compressed.length <= this.chunkBytes;
191
+ if (!inline) {
192
+ for (let offset = 0; offset < compressed.length; offset += this.chunkBytes) {
193
+ chunks.push(compressed.subarray(offset, offset + this.chunkBytes));
194
+ }
195
+ }
196
+ const writes = chunks.map((chunk, index) => ({
197
+ PutRequest: {
198
+ Item: {
199
+ pk: { S: pk },
200
+ sk: { S: this.chunkSortKey(version, index) },
201
+ data: { B: chunk },
202
+ expiresAt: { N: String(expiresAt) },
203
+ },
204
+ },
205
+ }));
206
+ await this.batchWrite(writes);
207
+ await this.client.send(new PutItemCommand({
208
+ TableName: this.tableName,
209
+ Item: {
210
+ pk: { S: pk },
211
+ sk: { S: META_SORT_KEY },
212
+ version: { S: version },
213
+ chunkCount: { N: String(chunks.length) },
214
+ compressedBytes: { N: String(compressed.length) },
215
+ uncompressedBytes: { N: String(input.length) },
216
+ checksum: { S: await sha256(compressed) },
217
+ encoding: { S: "br" },
218
+ createdAt: { N: String(this.nowSeconds()) },
219
+ expiresAt: { N: String(expiresAt) },
220
+ ...(inline ? { data: { B: compressed } } : {}),
221
+ },
222
+ }));
223
+ this.remember(normalizedKey, compressed, input.length, expiresAt);
224
+ }
225
+ async delete(key) {
226
+ const normalizedKey = this.normalizeKey(key);
227
+ const pk = await this.partitionKey(normalizedKey);
228
+ this.memory?.delete(normalizedKey);
229
+ const keys = [];
230
+ let cursor;
231
+ do {
232
+ const response = await this.client.send(new QueryCommand({
233
+ TableName: this.tableName,
234
+ KeyConditionExpression: "#pk = :pk",
235
+ ExpressionAttributeNames: { "#pk": "pk", "#sk": "sk" },
236
+ ExpressionAttributeValues: { ":pk": { S: pk } },
237
+ ProjectionExpression: "#pk, #sk",
238
+ ExclusiveStartKey: cursor,
239
+ }));
240
+ for (const item of response.Items ?? []) {
241
+ if (item.pk && item.sk)
242
+ keys.push({ pk: item.pk, sk: item.sk });
243
+ }
244
+ cursor = response.LastEvaluatedKey;
245
+ } while (cursor);
246
+ await this.batchWrite(keys.map((Key) => ({ DeleteRequest: { Key } })));
247
+ return keys.length > 0;
248
+ }
249
+ async getOrSet(key, factory, options = {}) {
250
+ const normalizedKey = this.normalizeKey(key);
251
+ const current = this.inFlight.get(normalizedKey);
252
+ if (current)
253
+ return current;
254
+ const fill = this.getOrSetFailOpen(normalizedKey, factory, options).finally(() => {
255
+ this.inFlight.delete(normalizedKey);
256
+ });
257
+ this.inFlight.set(normalizedKey, fill);
258
+ return fill;
259
+ }
260
+ /**
261
+ * Cache infrastructure is best-effort for getOrSet: read, lease, or write
262
+ * failures return the loader value. Loader failures still propagate and the
263
+ * loader is never repeated after it has completed successfully.
264
+ */
265
+ async getOrSetFailOpen(key, factory, options) {
266
+ let factoryStarted = false;
267
+ let factoryCompleted = false;
268
+ let factoryValue;
269
+ const trackedFactory = async () => {
270
+ factoryStarted = true;
271
+ factoryValue = await factory();
272
+ factoryCompleted = true;
273
+ return factoryValue;
274
+ };
275
+ try {
276
+ const existing = await this.get(key);
277
+ if (existing !== undefined)
278
+ return existing;
279
+ return await this.fill(key, trackedFactory, options);
280
+ }
281
+ catch (error) {
282
+ if (factoryStarted && !factoryCompleted)
283
+ throw error;
284
+ console.error(`DynamoDB cache failed open in ${this.namespace} for ${key}`, error);
285
+ if (factoryCompleted)
286
+ return factoryValue;
287
+ return trackedFactory();
288
+ }
289
+ }
290
+ async fill(key, factory, options) {
291
+ const leaseSeconds = positiveInteger(options.leaseSeconds ?? 15, "leaseSeconds");
292
+ const waitForFillMs = positiveInteger(options.waitForFillMs ?? 5_000, "waitForFillMs");
293
+ const pk = await this.partitionKey(key);
294
+ const owner = await randomUUID();
295
+ if (await this.acquireLease(pk, owner, leaseSeconds)) {
296
+ try {
297
+ const value = await factory();
298
+ await this.set(key, value, { ttlSeconds: options.ttlSeconds });
299
+ return value;
300
+ }
301
+ finally {
302
+ await this.releaseLease(pk, owner);
303
+ }
304
+ }
305
+ const deadline = Date.now() + waitForFillMs;
306
+ let delay = 50;
307
+ while (Date.now() < deadline) {
308
+ await sleep(delay + Math.floor(Math.random() * 25));
309
+ const value = await this.get(key);
310
+ if (value !== undefined)
311
+ return value;
312
+ if (await this.acquireLease(pk, owner, leaseSeconds)) {
313
+ try {
314
+ const loaded = await factory();
315
+ await this.set(key, loaded, { ttlSeconds: options.ttlSeconds });
316
+ return loaded;
317
+ }
318
+ finally {
319
+ await this.releaseLease(pk, owner);
320
+ }
321
+ }
322
+ delay = Math.min(delay * 2, 500);
323
+ }
324
+ throw new Error(`Timed out waiting for DynamoDB cache fill in ${this.namespace}`);
325
+ }
326
+ async acquireLease(pk, owner, leaseSeconds) {
327
+ const now = this.nowSeconds();
328
+ try {
329
+ await this.client.send(new PutItemCommand({
330
+ TableName: this.tableName,
331
+ Item: {
332
+ pk: { S: pk },
333
+ sk: { S: LOCK_SORT_KEY },
334
+ owner: { S: owner },
335
+ expiresAt: { N: String(now + leaseSeconds) },
336
+ },
337
+ ConditionExpression: "attribute_not_exists(#pk) OR #expiresAt < :now",
338
+ ExpressionAttributeNames: { "#pk": "pk", "#expiresAt": "expiresAt" },
339
+ ExpressionAttributeValues: { ":now": { N: String(now) } },
340
+ }));
341
+ return true;
342
+ }
343
+ catch (error) {
344
+ if (this.isConditionalFailure(error))
345
+ return false;
346
+ throw error;
347
+ }
348
+ }
349
+ async releaseLease(pk, owner) {
350
+ try {
351
+ await this.client.send(new DeleteItemCommand({
352
+ TableName: this.tableName,
353
+ Key: { pk: { S: pk }, sk: { S: LOCK_SORT_KEY } },
354
+ ConditionExpression: "#owner = :owner",
355
+ ExpressionAttributeNames: { "#owner": "owner" },
356
+ ExpressionAttributeValues: { ":owner": { S: owner } },
357
+ }));
358
+ }
359
+ catch (error) {
360
+ if (!this.isConditionalFailure(error)) {
361
+ console.warn(`Failed to release DynamoDB cache lease in ${this.namespace}`, error);
362
+ }
363
+ }
364
+ }
365
+ async readManifest(pk) {
366
+ const response = await this.client.send(new GetItemCommand({
367
+ TableName: this.tableName,
368
+ Key: { pk: { S: pk }, sk: { S: META_SORT_KEY } },
369
+ ConsistentRead: false,
370
+ }));
371
+ const item = response.Item;
372
+ if (!item)
373
+ return undefined;
374
+ const version = item.version?.S;
375
+ const encoding = item.encoding?.S;
376
+ const chunkCount = Number(item.chunkCount?.N);
377
+ const compressedBytes = Number(item.compressedBytes?.N);
378
+ const uncompressedBytes = Number(item.uncompressedBytes?.N);
379
+ const expiresAt = Number(item.expiresAt?.N);
380
+ const checksum = item.checksum?.S;
381
+ const inlineData = item.data?.B == null ? undefined : Buffer.from(item.data.B);
382
+ const validInline = inlineData !== undefined &&
383
+ chunkCount === 0 &&
384
+ inlineData.length === compressedBytes &&
385
+ compressedBytes <= this.chunkBytes;
386
+ const validChunks = inlineData === undefined &&
387
+ chunkCount > 0 &&
388
+ chunkCount === Math.ceil(compressedBytes / this.chunkBytes);
389
+ if (!version ||
390
+ encoding !== "br" ||
391
+ !checksum ||
392
+ !Number.isSafeInteger(chunkCount) ||
393
+ chunkCount < 0 ||
394
+ !Number.isSafeInteger(compressedBytes) ||
395
+ compressedBytes < 0 ||
396
+ compressedBytes > this.maxValueBytes ||
397
+ !Number.isSafeInteger(uncompressedBytes) ||
398
+ uncompressedBytes < 0 ||
399
+ uncompressedBytes > this.maxValueBytes ||
400
+ !Number.isSafeInteger(expiresAt) ||
401
+ (!validInline && !validChunks)) {
402
+ return undefined;
403
+ }
404
+ return {
405
+ version,
406
+ chunkCount,
407
+ compressedBytes,
408
+ uncompressedBytes,
409
+ checksum,
410
+ expiresAt,
411
+ inlineData,
412
+ };
413
+ }
414
+ async readChunks(pk, manifest) {
415
+ const prefix = `chunk#${manifest.version}#`;
416
+ const chunks = [];
417
+ let cursor;
418
+ do {
419
+ const response = await this.client.send(new QueryCommand({
420
+ TableName: this.tableName,
421
+ KeyConditionExpression: "#pk = :pk AND begins_with(#sk, :prefix)",
422
+ ExpressionAttributeValues: { ":pk": { S: pk }, ":prefix": { S: prefix } },
423
+ ProjectionExpression: "#sk, #data",
424
+ ExpressionAttributeNames: { "#pk": "pk", "#sk": "sk", "#data": "data" },
425
+ ExclusiveStartKey: cursor,
426
+ ConsistentRead: false,
427
+ }));
428
+ for (const item of response.Items ?? []) {
429
+ if (item.sk?.S && item.data?.B) {
430
+ chunks.push({ sk: item.sk.S, data: Buffer.from(item.data.B) });
431
+ }
432
+ }
433
+ cursor = response.LastEvaluatedKey;
434
+ } while (cursor);
435
+ chunks.sort((left, right) => left.sk.localeCompare(right.sk));
436
+ if (chunks.length !== manifest.chunkCount) {
437
+ throw new Error(`DynamoDB cache entry is missing chunks (${chunks.length}/${manifest.chunkCount})`);
438
+ }
439
+ for (let index = 0; index < chunks.length; index += 1) {
440
+ if (chunks[index]?.sk !== this.chunkSortKey(manifest.version, index)) {
441
+ throw new Error(`DynamoDB cache entry has an invalid chunk index at ${index}`);
442
+ }
443
+ }
444
+ return Buffer.concat(chunks.map((chunk) => chunk.data), manifest.compressedBytes);
445
+ }
446
+ async invalidateManifest(pk, version) {
447
+ try {
448
+ await this.client.send(new DeleteItemCommand({
449
+ TableName: this.tableName,
450
+ Key: { pk: { S: pk }, sk: { S: META_SORT_KEY } },
451
+ ConditionExpression: "#version = :version",
452
+ ExpressionAttributeNames: { "#version": "version" },
453
+ ExpressionAttributeValues: { ":version": { S: version } },
454
+ }));
455
+ }
456
+ catch (error) {
457
+ if (!this.isConditionalFailure(error)) {
458
+ console.warn(`Failed to invalidate corrupt DynamoDB cache manifest in ${this.namespace}`, error);
459
+ }
460
+ }
461
+ }
462
+ async batchWrite(requests) {
463
+ for (let offset = 0; offset < requests.length; offset += BATCH_WRITE_LIMIT) {
464
+ let pending = requests.slice(offset, offset + BATCH_WRITE_LIMIT);
465
+ for (let attempt = 0; pending.length > 0; attempt += 1) {
466
+ if (attempt >= MAX_BATCH_RETRIES) {
467
+ throw new Error(`DynamoDB cache batch write remained throttled after ${MAX_BATCH_RETRIES} attempts`);
468
+ }
469
+ const response = await this.client.send(new BatchWriteItemCommand({ RequestItems: { [this.tableName]: pending } }));
470
+ pending = response.UnprocessedItems?.[this.tableName] ?? [];
471
+ if (pending.length > 0) {
472
+ const backoff = Math.min(25 * 2 ** attempt, 1_000) + Math.floor(Math.random() * 50);
473
+ await sleep(backoff);
474
+ }
475
+ }
476
+ }
477
+ }
478
+ remember(key, compressed, uncompressedBytes, expiresAt) {
479
+ if (!this.memory)
480
+ return;
481
+ const ttl = expiresAt * 1000 - Date.now();
482
+ if (ttl <= 0)
483
+ return;
484
+ this.memory.set(key, { compressed, uncompressedBytes, expiresAt }, { ttl });
485
+ }
486
+ normalizeKey(key) {
487
+ if (typeof key !== "string" || !key.trim())
488
+ throw new Error("Cache key is required");
489
+ if (Buffer.byteLength(key, "utf8") > 8 * 1024) {
490
+ throw new Error("Cache key must be at most 8192 UTF-8 bytes");
491
+ }
492
+ return key;
493
+ }
494
+ async partitionKey(key) {
495
+ return `${this.namespace}#${await sha256(key)}`;
496
+ }
497
+ chunkSortKey(version, index) {
498
+ return `chunk#${version}#${String(index).padStart(6, "0")}`;
499
+ }
500
+ nowSeconds() {
501
+ return Math.floor(Date.now() / 1000);
502
+ }
503
+ isConditionalFailure(error) {
504
+ return !!error && typeof error === "object" && "name" in error && error.name === "ConditionalCheckFailedException";
505
+ }
506
+ }
package/dist/index.d.ts CHANGED
@@ -17,6 +17,8 @@ export { LambderPublicFilesHandler } from "./LambderPublicFiles.js";
17
17
  export type { LambderPublicFilesOptions } from "./LambderPublicFiles.js";
18
18
  export type { LambderSessionCookieOptions } from "./LambderSessionController.js";
19
19
  export type { LambderSessionContext } from "./LambderSessionManager.js";
20
+ export { LambderDdbCache } from "./LambderDdbCache.js";
21
+ export type { LambderDdbCacheOptions, LambderDdbCacheSetOptions, LambderDdbCacheGetOrSetOptions, } from "./LambderDdbCache.js";
20
22
  export { type ApiContractShape, } from "./LambderApiContract.js";
21
23
  export type { LambderRenderContext, LambderSessionRenderContext, LambderHttpEvent } from "./LambderContext.js";
22
24
  export { createContext, isV2HttpEvent } from "./LambderContext.js";
package/dist/index.js CHANGED
@@ -14,4 +14,6 @@ export { html, xml, raw, jsonScript, escapeHtml, renderHtmlValue, LambderSafeHtm
14
14
  export { LambderTemplatingEngine } from "./LambderTemplatingEngine.js";
15
15
  // Public file serving
16
16
  export { LambderPublicFilesHandler } from "./LambderPublicFiles.js";
17
+ // DynamoDB-backed compressed cache (standalone, server-only)
18
+ export { LambderDdbCache } from "./LambderDdbCache.js";
17
19
  export { createContext, isV2HttpEvent } from "./LambderContext.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lambder",
3
- "version": "3.0.0",
3
+ "version": "3.1.2",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -37,6 +37,7 @@
37
37
  "@aws-sdk/lib-dynamodb": "^3.574.0",
38
38
  "cookie": "^1.0.2",
39
39
  "js-cookie": "^3.0.5",
40
+ "lru-cache": "^11.5.2",
40
41
  "mime-types": "^2.1.35",
41
42
  "path-to-regexp": "^6.2.1",
42
43
  "zod": "^4.1.12"