lambder 3.0.0 → 3.1.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 +22 -0
- package/dist/LambderDdbCache.d.ts +65 -0
- package/dist/LambderDdbCache.js +480 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/package.json +2 -1
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,480 @@
|
|
|
1
|
+
import { BatchWriteItemCommand, DeleteItemCommand, DynamoDBClient, GetItemCommand, PutItemCommand, QueryCommand, } from "@aws-sdk/client-dynamodb";
|
|
2
|
+
import { createHash, randomUUID } from "crypto";
|
|
3
|
+
import { brotliCompress, brotliDecompress, constants as zlibConstants, } from "zlib";
|
|
4
|
+
import { LRUCache } from "lru-cache";
|
|
5
|
+
const DEFAULT_TTL_SECONDS = 365 * 24 * 60 * 60;
|
|
6
|
+
const DEFAULT_CHUNK_BYTES = 350 * 1024;
|
|
7
|
+
const MAX_SAFE_CHUNK_BYTES = 380 * 1024;
|
|
8
|
+
const DEFAULT_MAX_VALUE_BYTES = 32 * 1024 * 1024;
|
|
9
|
+
const DEFAULT_MEMORY_BYTES = 16 * 1024 * 1024;
|
|
10
|
+
const META_SORT_KEY = "meta";
|
|
11
|
+
const LOCK_SORT_KEY = "lock";
|
|
12
|
+
const BATCH_WRITE_LIMIT = 25;
|
|
13
|
+
const MAX_BATCH_RETRIES = 8;
|
|
14
|
+
const compress = (input, quality) => new Promise((resolve, reject) => {
|
|
15
|
+
const options = {
|
|
16
|
+
params: {
|
|
17
|
+
[zlibConstants.BROTLI_PARAM_QUALITY]: quality,
|
|
18
|
+
[zlibConstants.BROTLI_PARAM_MODE]: zlibConstants.BROTLI_MODE_TEXT,
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
brotliCompress(input, options, (error, output) => {
|
|
22
|
+
if (error)
|
|
23
|
+
reject(error);
|
|
24
|
+
else
|
|
25
|
+
resolve(output);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
const decompress = (input, maxOutputLength) => new Promise((resolve, reject) => {
|
|
29
|
+
brotliDecompress(input, { maxOutputLength }, (error, output) => {
|
|
30
|
+
if (error)
|
|
31
|
+
reject(error);
|
|
32
|
+
else
|
|
33
|
+
resolve(output);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
|
37
|
+
const positiveInteger = (value, name) => {
|
|
38
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
39
|
+
throw new Error(`${name} must be a positive safe integer`);
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
};
|
|
43
|
+
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
44
|
+
/**
|
|
45
|
+
* Persistent JSON cache backed by DynamoDB.
|
|
46
|
+
*
|
|
47
|
+
* Values are Brotli-compressed. Values within the safe DynamoDB item budget are
|
|
48
|
+
* stored directly in the manifest for a single-request read; larger values are
|
|
49
|
+
* split into versioned binary chunks. A manifest is written only after every
|
|
50
|
+
* chunk succeeds, so readers see either the previous complete version or the
|
|
51
|
+
* new complete version. DynamoDB TTL is cleanup only; every read also checks
|
|
52
|
+
* expiresAt because TTL deletion can lag.
|
|
53
|
+
*/
|
|
54
|
+
export class LambderDdbCache {
|
|
55
|
+
tableName;
|
|
56
|
+
namespace;
|
|
57
|
+
client;
|
|
58
|
+
defaultTtlSeconds;
|
|
59
|
+
chunkBytes;
|
|
60
|
+
compressionQuality;
|
|
61
|
+
maxValueBytes;
|
|
62
|
+
memory;
|
|
63
|
+
inFlight = new Map();
|
|
64
|
+
constructor(options) {
|
|
65
|
+
if (!options.tableName.trim())
|
|
66
|
+
throw new Error("tableName is required");
|
|
67
|
+
this.tableName = options.tableName;
|
|
68
|
+
this.namespace = options.namespace?.trim() || "default";
|
|
69
|
+
if (Buffer.byteLength(this.namespace, "utf8") > 128) {
|
|
70
|
+
throw new Error("namespace must be at most 128 UTF-8 bytes");
|
|
71
|
+
}
|
|
72
|
+
this.defaultTtlSeconds = positiveInteger(options.defaultTtlSeconds ?? DEFAULT_TTL_SECONDS, "defaultTtlSeconds");
|
|
73
|
+
this.chunkBytes = positiveInteger(options.chunkBytes ?? DEFAULT_CHUNK_BYTES, "chunkBytes");
|
|
74
|
+
if (this.chunkBytes > MAX_SAFE_CHUNK_BYTES) {
|
|
75
|
+
throw new Error(`chunkBytes must not exceed ${MAX_SAFE_CHUNK_BYTES}`);
|
|
76
|
+
}
|
|
77
|
+
this.compressionQuality = options.compressionQuality ?? 5;
|
|
78
|
+
if (!Number.isInteger(this.compressionQuality) || this.compressionQuality < 0 || this.compressionQuality > 11) {
|
|
79
|
+
throw new Error("compressionQuality must be an integer from 0 to 11");
|
|
80
|
+
}
|
|
81
|
+
this.maxValueBytes = positiveInteger(options.maxValueBytes ?? DEFAULT_MAX_VALUE_BYTES, "maxValueBytes");
|
|
82
|
+
const memoryMaxBytes = options.memoryMaxBytes ?? DEFAULT_MEMORY_BYTES;
|
|
83
|
+
this.memory = memoryMaxBytes === 0
|
|
84
|
+
? null
|
|
85
|
+
: new LRUCache({
|
|
86
|
+
maxSize: positiveInteger(memoryMaxBytes, "memoryMaxBytes"),
|
|
87
|
+
sizeCalculation: (entry) => entry.compressed.length,
|
|
88
|
+
});
|
|
89
|
+
this.client = options.client ?? new DynamoDBClient({ region: options.region ?? "us-east-1" });
|
|
90
|
+
}
|
|
91
|
+
async get(key) {
|
|
92
|
+
const normalizedKey = this.normalizeKey(key);
|
|
93
|
+
const cached = this.memory?.get(normalizedKey);
|
|
94
|
+
const nowSeconds = this.nowSeconds();
|
|
95
|
+
if (cached && cached.expiresAt > nowSeconds) {
|
|
96
|
+
try {
|
|
97
|
+
const output = await decompress(cached.compressed, this.maxValueBytes);
|
|
98
|
+
if (output.length === cached.uncompressedBytes) {
|
|
99
|
+
return JSON.parse(output.toString("utf8"));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// Fall through to DynamoDB; the in-memory copy is disposable.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (cached)
|
|
107
|
+
this.memory?.delete(normalizedKey);
|
|
108
|
+
const pk = this.partitionKey(normalizedKey);
|
|
109
|
+
const manifest = await this.readManifest(pk);
|
|
110
|
+
if (!manifest || manifest.expiresAt <= nowSeconds)
|
|
111
|
+
return undefined;
|
|
112
|
+
try {
|
|
113
|
+
const compressed = manifest.inlineData ?? await this.readChunks(pk, manifest);
|
|
114
|
+
if (compressed.length !== manifest.compressedBytes) {
|
|
115
|
+
throw new Error("compressed byte length does not match manifest");
|
|
116
|
+
}
|
|
117
|
+
if (sha256(compressed) !== manifest.checksum) {
|
|
118
|
+
throw new Error("compressed checksum does not match manifest");
|
|
119
|
+
}
|
|
120
|
+
const output = await decompress(compressed, this.maxValueBytes);
|
|
121
|
+
if (output.length !== manifest.uncompressedBytes) {
|
|
122
|
+
throw new Error("uncompressed byte length does not match manifest");
|
|
123
|
+
}
|
|
124
|
+
const json = output.toString("utf8");
|
|
125
|
+
const parsed = JSON.parse(json);
|
|
126
|
+
this.remember(normalizedKey, compressed, output.length, manifest.expiresAt);
|
|
127
|
+
return parsed;
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
await this.invalidateManifest(pk, manifest.version);
|
|
131
|
+
console.warn(`Ignoring corrupt DynamoDB cache entry in ${this.namespace}`, error);
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
async has(key) {
|
|
136
|
+
const normalizedKey = this.normalizeKey(key);
|
|
137
|
+
const cached = this.memory?.get(normalizedKey);
|
|
138
|
+
const nowSeconds = this.nowSeconds();
|
|
139
|
+
if (cached?.expiresAt && cached.expiresAt > nowSeconds)
|
|
140
|
+
return true;
|
|
141
|
+
if (cached)
|
|
142
|
+
this.memory?.delete(normalizedKey);
|
|
143
|
+
const manifest = await this.readManifest(this.partitionKey(normalizedKey));
|
|
144
|
+
return !!manifest && manifest.expiresAt > nowSeconds;
|
|
145
|
+
}
|
|
146
|
+
async set(key, value, options = {}) {
|
|
147
|
+
const normalizedKey = this.normalizeKey(key);
|
|
148
|
+
const ttlSeconds = positiveInteger(options.ttlSeconds ?? this.defaultTtlSeconds, "ttlSeconds");
|
|
149
|
+
const json = JSON.stringify(value);
|
|
150
|
+
if (json === undefined)
|
|
151
|
+
throw new Error("Cache value must be JSON-serializable");
|
|
152
|
+
const input = Buffer.from(json, "utf8");
|
|
153
|
+
if (input.length > this.maxValueBytes) {
|
|
154
|
+
throw new Error(`Cache value exceeds maxValueBytes (${input.length} > ${this.maxValueBytes})`);
|
|
155
|
+
}
|
|
156
|
+
const compressed = await compress(input, this.compressionQuality);
|
|
157
|
+
if (compressed.length > this.maxValueBytes) {
|
|
158
|
+
throw new Error(`Compressed cache value exceeds maxValueBytes (${compressed.length} > ${this.maxValueBytes})`);
|
|
159
|
+
}
|
|
160
|
+
const pk = this.partitionKey(normalizedKey);
|
|
161
|
+
const version = `${Date.now().toString(36)}-${randomUUID()}`;
|
|
162
|
+
const expiresAt = this.nowSeconds() + ttlSeconds;
|
|
163
|
+
const chunks = [];
|
|
164
|
+
const inline = compressed.length <= this.chunkBytes;
|
|
165
|
+
if (!inline) {
|
|
166
|
+
for (let offset = 0; offset < compressed.length; offset += this.chunkBytes) {
|
|
167
|
+
chunks.push(compressed.subarray(offset, offset + this.chunkBytes));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const writes = chunks.map((chunk, index) => ({
|
|
171
|
+
PutRequest: {
|
|
172
|
+
Item: {
|
|
173
|
+
pk: { S: pk },
|
|
174
|
+
sk: { S: this.chunkSortKey(version, index) },
|
|
175
|
+
data: { B: chunk },
|
|
176
|
+
expiresAt: { N: String(expiresAt) },
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
}));
|
|
180
|
+
await this.batchWrite(writes);
|
|
181
|
+
await this.client.send(new PutItemCommand({
|
|
182
|
+
TableName: this.tableName,
|
|
183
|
+
Item: {
|
|
184
|
+
pk: { S: pk },
|
|
185
|
+
sk: { S: META_SORT_KEY },
|
|
186
|
+
version: { S: version },
|
|
187
|
+
chunkCount: { N: String(chunks.length) },
|
|
188
|
+
compressedBytes: { N: String(compressed.length) },
|
|
189
|
+
uncompressedBytes: { N: String(input.length) },
|
|
190
|
+
checksum: { S: sha256(compressed) },
|
|
191
|
+
encoding: { S: "br" },
|
|
192
|
+
createdAt: { N: String(this.nowSeconds()) },
|
|
193
|
+
expiresAt: { N: String(expiresAt) },
|
|
194
|
+
...(inline ? { data: { B: compressed } } : {}),
|
|
195
|
+
},
|
|
196
|
+
}));
|
|
197
|
+
this.remember(normalizedKey, compressed, input.length, expiresAt);
|
|
198
|
+
}
|
|
199
|
+
async delete(key) {
|
|
200
|
+
const normalizedKey = this.normalizeKey(key);
|
|
201
|
+
const pk = this.partitionKey(normalizedKey);
|
|
202
|
+
this.memory?.delete(normalizedKey);
|
|
203
|
+
const keys = [];
|
|
204
|
+
let cursor;
|
|
205
|
+
do {
|
|
206
|
+
const response = await this.client.send(new QueryCommand({
|
|
207
|
+
TableName: this.tableName,
|
|
208
|
+
KeyConditionExpression: "#pk = :pk",
|
|
209
|
+
ExpressionAttributeNames: { "#pk": "pk", "#sk": "sk" },
|
|
210
|
+
ExpressionAttributeValues: { ":pk": { S: pk } },
|
|
211
|
+
ProjectionExpression: "#pk, #sk",
|
|
212
|
+
ExclusiveStartKey: cursor,
|
|
213
|
+
}));
|
|
214
|
+
for (const item of response.Items ?? []) {
|
|
215
|
+
if (item.pk && item.sk)
|
|
216
|
+
keys.push({ pk: item.pk, sk: item.sk });
|
|
217
|
+
}
|
|
218
|
+
cursor = response.LastEvaluatedKey;
|
|
219
|
+
} while (cursor);
|
|
220
|
+
await this.batchWrite(keys.map((Key) => ({ DeleteRequest: { Key } })));
|
|
221
|
+
return keys.length > 0;
|
|
222
|
+
}
|
|
223
|
+
async getOrSet(key, factory, options = {}) {
|
|
224
|
+
const normalizedKey = this.normalizeKey(key);
|
|
225
|
+
const current = this.inFlight.get(normalizedKey);
|
|
226
|
+
if (current)
|
|
227
|
+
return current;
|
|
228
|
+
const fill = this.getOrSetFailOpen(normalizedKey, factory, options).finally(() => {
|
|
229
|
+
this.inFlight.delete(normalizedKey);
|
|
230
|
+
});
|
|
231
|
+
this.inFlight.set(normalizedKey, fill);
|
|
232
|
+
return fill;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Cache infrastructure is best-effort for getOrSet: read, lease, or write
|
|
236
|
+
* failures return the loader value. Loader failures still propagate and the
|
|
237
|
+
* loader is never repeated after it has completed successfully.
|
|
238
|
+
*/
|
|
239
|
+
async getOrSetFailOpen(key, factory, options) {
|
|
240
|
+
let factoryStarted = false;
|
|
241
|
+
let factoryCompleted = false;
|
|
242
|
+
let factoryValue;
|
|
243
|
+
const trackedFactory = async () => {
|
|
244
|
+
factoryStarted = true;
|
|
245
|
+
factoryValue = await factory();
|
|
246
|
+
factoryCompleted = true;
|
|
247
|
+
return factoryValue;
|
|
248
|
+
};
|
|
249
|
+
try {
|
|
250
|
+
const existing = await this.get(key);
|
|
251
|
+
if (existing !== undefined)
|
|
252
|
+
return existing;
|
|
253
|
+
return await this.fill(key, trackedFactory, options);
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
if (factoryStarted && !factoryCompleted)
|
|
257
|
+
throw error;
|
|
258
|
+
console.error(`DynamoDB cache failed open in ${this.namespace} for ${key}`, error);
|
|
259
|
+
if (factoryCompleted)
|
|
260
|
+
return factoryValue;
|
|
261
|
+
return trackedFactory();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
async fill(key, factory, options) {
|
|
265
|
+
const leaseSeconds = positiveInteger(options.leaseSeconds ?? 15, "leaseSeconds");
|
|
266
|
+
const waitForFillMs = positiveInteger(options.waitForFillMs ?? 5_000, "waitForFillMs");
|
|
267
|
+
const pk = this.partitionKey(key);
|
|
268
|
+
const owner = randomUUID();
|
|
269
|
+
if (await this.acquireLease(pk, owner, leaseSeconds)) {
|
|
270
|
+
try {
|
|
271
|
+
const value = await factory();
|
|
272
|
+
await this.set(key, value, { ttlSeconds: options.ttlSeconds });
|
|
273
|
+
return value;
|
|
274
|
+
}
|
|
275
|
+
finally {
|
|
276
|
+
await this.releaseLease(pk, owner);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const deadline = Date.now() + waitForFillMs;
|
|
280
|
+
let delay = 50;
|
|
281
|
+
while (Date.now() < deadline) {
|
|
282
|
+
await sleep(delay + Math.floor(Math.random() * 25));
|
|
283
|
+
const value = await this.get(key);
|
|
284
|
+
if (value !== undefined)
|
|
285
|
+
return value;
|
|
286
|
+
if (await this.acquireLease(pk, owner, leaseSeconds)) {
|
|
287
|
+
try {
|
|
288
|
+
const loaded = await factory();
|
|
289
|
+
await this.set(key, loaded, { ttlSeconds: options.ttlSeconds });
|
|
290
|
+
return loaded;
|
|
291
|
+
}
|
|
292
|
+
finally {
|
|
293
|
+
await this.releaseLease(pk, owner);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
delay = Math.min(delay * 2, 500);
|
|
297
|
+
}
|
|
298
|
+
throw new Error(`Timed out waiting for DynamoDB cache fill in ${this.namespace}`);
|
|
299
|
+
}
|
|
300
|
+
async acquireLease(pk, owner, leaseSeconds) {
|
|
301
|
+
const now = this.nowSeconds();
|
|
302
|
+
try {
|
|
303
|
+
await this.client.send(new PutItemCommand({
|
|
304
|
+
TableName: this.tableName,
|
|
305
|
+
Item: {
|
|
306
|
+
pk: { S: pk },
|
|
307
|
+
sk: { S: LOCK_SORT_KEY },
|
|
308
|
+
owner: { S: owner },
|
|
309
|
+
expiresAt: { N: String(now + leaseSeconds) },
|
|
310
|
+
},
|
|
311
|
+
ConditionExpression: "attribute_not_exists(#pk) OR #expiresAt < :now",
|
|
312
|
+
ExpressionAttributeNames: { "#pk": "pk", "#expiresAt": "expiresAt" },
|
|
313
|
+
ExpressionAttributeValues: { ":now": { N: String(now) } },
|
|
314
|
+
}));
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
if (this.isConditionalFailure(error))
|
|
319
|
+
return false;
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
async releaseLease(pk, owner) {
|
|
324
|
+
try {
|
|
325
|
+
await this.client.send(new DeleteItemCommand({
|
|
326
|
+
TableName: this.tableName,
|
|
327
|
+
Key: { pk: { S: pk }, sk: { S: LOCK_SORT_KEY } },
|
|
328
|
+
ConditionExpression: "#owner = :owner",
|
|
329
|
+
ExpressionAttributeNames: { "#owner": "owner" },
|
|
330
|
+
ExpressionAttributeValues: { ":owner": { S: owner } },
|
|
331
|
+
}));
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
if (!this.isConditionalFailure(error)) {
|
|
335
|
+
console.warn(`Failed to release DynamoDB cache lease in ${this.namespace}`, error);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
async readManifest(pk) {
|
|
340
|
+
const response = await this.client.send(new GetItemCommand({
|
|
341
|
+
TableName: this.tableName,
|
|
342
|
+
Key: { pk: { S: pk }, sk: { S: META_SORT_KEY } },
|
|
343
|
+
ConsistentRead: false,
|
|
344
|
+
}));
|
|
345
|
+
const item = response.Item;
|
|
346
|
+
if (!item)
|
|
347
|
+
return undefined;
|
|
348
|
+
const version = item.version?.S;
|
|
349
|
+
const encoding = item.encoding?.S;
|
|
350
|
+
const chunkCount = Number(item.chunkCount?.N);
|
|
351
|
+
const compressedBytes = Number(item.compressedBytes?.N);
|
|
352
|
+
const uncompressedBytes = Number(item.uncompressedBytes?.N);
|
|
353
|
+
const expiresAt = Number(item.expiresAt?.N);
|
|
354
|
+
const checksum = item.checksum?.S;
|
|
355
|
+
const inlineData = item.data?.B == null ? undefined : Buffer.from(item.data.B);
|
|
356
|
+
const validInline = inlineData !== undefined &&
|
|
357
|
+
chunkCount === 0 &&
|
|
358
|
+
inlineData.length === compressedBytes &&
|
|
359
|
+
compressedBytes <= this.chunkBytes;
|
|
360
|
+
const validChunks = inlineData === undefined &&
|
|
361
|
+
chunkCount > 0 &&
|
|
362
|
+
chunkCount === Math.ceil(compressedBytes / this.chunkBytes);
|
|
363
|
+
if (!version ||
|
|
364
|
+
encoding !== "br" ||
|
|
365
|
+
!checksum ||
|
|
366
|
+
!Number.isSafeInteger(chunkCount) ||
|
|
367
|
+
chunkCount < 0 ||
|
|
368
|
+
!Number.isSafeInteger(compressedBytes) ||
|
|
369
|
+
compressedBytes < 0 ||
|
|
370
|
+
compressedBytes > this.maxValueBytes ||
|
|
371
|
+
!Number.isSafeInteger(uncompressedBytes) ||
|
|
372
|
+
uncompressedBytes < 0 ||
|
|
373
|
+
uncompressedBytes > this.maxValueBytes ||
|
|
374
|
+
!Number.isSafeInteger(expiresAt) ||
|
|
375
|
+
(!validInline && !validChunks)) {
|
|
376
|
+
return undefined;
|
|
377
|
+
}
|
|
378
|
+
return {
|
|
379
|
+
version,
|
|
380
|
+
chunkCount,
|
|
381
|
+
compressedBytes,
|
|
382
|
+
uncompressedBytes,
|
|
383
|
+
checksum,
|
|
384
|
+
expiresAt,
|
|
385
|
+
inlineData,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
async readChunks(pk, manifest) {
|
|
389
|
+
const prefix = `chunk#${manifest.version}#`;
|
|
390
|
+
const chunks = [];
|
|
391
|
+
let cursor;
|
|
392
|
+
do {
|
|
393
|
+
const response = await this.client.send(new QueryCommand({
|
|
394
|
+
TableName: this.tableName,
|
|
395
|
+
KeyConditionExpression: "#pk = :pk AND begins_with(#sk, :prefix)",
|
|
396
|
+
ExpressionAttributeValues: { ":pk": { S: pk }, ":prefix": { S: prefix } },
|
|
397
|
+
ProjectionExpression: "#sk, #data",
|
|
398
|
+
ExpressionAttributeNames: { "#pk": "pk", "#sk": "sk", "#data": "data" },
|
|
399
|
+
ExclusiveStartKey: cursor,
|
|
400
|
+
ConsistentRead: false,
|
|
401
|
+
}));
|
|
402
|
+
for (const item of response.Items ?? []) {
|
|
403
|
+
if (item.sk?.S && item.data?.B) {
|
|
404
|
+
chunks.push({ sk: item.sk.S, data: Buffer.from(item.data.B) });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
cursor = response.LastEvaluatedKey;
|
|
408
|
+
} while (cursor);
|
|
409
|
+
chunks.sort((left, right) => left.sk.localeCompare(right.sk));
|
|
410
|
+
if (chunks.length !== manifest.chunkCount) {
|
|
411
|
+
throw new Error(`DynamoDB cache entry is missing chunks (${chunks.length}/${manifest.chunkCount})`);
|
|
412
|
+
}
|
|
413
|
+
for (let index = 0; index < chunks.length; index += 1) {
|
|
414
|
+
if (chunks[index]?.sk !== this.chunkSortKey(manifest.version, index)) {
|
|
415
|
+
throw new Error(`DynamoDB cache entry has an invalid chunk index at ${index}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return Buffer.concat(chunks.map((chunk) => chunk.data), manifest.compressedBytes);
|
|
419
|
+
}
|
|
420
|
+
async invalidateManifest(pk, version) {
|
|
421
|
+
try {
|
|
422
|
+
await this.client.send(new DeleteItemCommand({
|
|
423
|
+
TableName: this.tableName,
|
|
424
|
+
Key: { pk: { S: pk }, sk: { S: META_SORT_KEY } },
|
|
425
|
+
ConditionExpression: "#version = :version",
|
|
426
|
+
ExpressionAttributeNames: { "#version": "version" },
|
|
427
|
+
ExpressionAttributeValues: { ":version": { S: version } },
|
|
428
|
+
}));
|
|
429
|
+
}
|
|
430
|
+
catch (error) {
|
|
431
|
+
if (!this.isConditionalFailure(error)) {
|
|
432
|
+
console.warn(`Failed to invalidate corrupt DynamoDB cache manifest in ${this.namespace}`, error);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
async batchWrite(requests) {
|
|
437
|
+
for (let offset = 0; offset < requests.length; offset += BATCH_WRITE_LIMIT) {
|
|
438
|
+
let pending = requests.slice(offset, offset + BATCH_WRITE_LIMIT);
|
|
439
|
+
for (let attempt = 0; pending.length > 0; attempt += 1) {
|
|
440
|
+
if (attempt >= MAX_BATCH_RETRIES) {
|
|
441
|
+
throw new Error(`DynamoDB cache batch write remained throttled after ${MAX_BATCH_RETRIES} attempts`);
|
|
442
|
+
}
|
|
443
|
+
const response = await this.client.send(new BatchWriteItemCommand({ RequestItems: { [this.tableName]: pending } }));
|
|
444
|
+
pending = response.UnprocessedItems?.[this.tableName] ?? [];
|
|
445
|
+
if (pending.length > 0) {
|
|
446
|
+
const backoff = Math.min(25 * 2 ** attempt, 1_000) + Math.floor(Math.random() * 50);
|
|
447
|
+
await sleep(backoff);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
remember(key, compressed, uncompressedBytes, expiresAt) {
|
|
453
|
+
if (!this.memory)
|
|
454
|
+
return;
|
|
455
|
+
const ttl = expiresAt * 1000 - Date.now();
|
|
456
|
+
if (ttl <= 0)
|
|
457
|
+
return;
|
|
458
|
+
this.memory.set(key, { compressed, uncompressedBytes, expiresAt }, { ttl });
|
|
459
|
+
}
|
|
460
|
+
normalizeKey(key) {
|
|
461
|
+
if (typeof key !== "string" || !key.trim())
|
|
462
|
+
throw new Error("Cache key is required");
|
|
463
|
+
if (Buffer.byteLength(key, "utf8") > 8 * 1024) {
|
|
464
|
+
throw new Error("Cache key must be at most 8192 UTF-8 bytes");
|
|
465
|
+
}
|
|
466
|
+
return key;
|
|
467
|
+
}
|
|
468
|
+
partitionKey(key) {
|
|
469
|
+
return `${this.namespace}#${sha256(key)}`;
|
|
470
|
+
}
|
|
471
|
+
chunkSortKey(version, index) {
|
|
472
|
+
return `chunk#${version}#${String(index).padStart(6, "0")}`;
|
|
473
|
+
}
|
|
474
|
+
nowSeconds() {
|
|
475
|
+
return Math.floor(Date.now() / 1000);
|
|
476
|
+
}
|
|
477
|
+
isConditionalFailure(error) {
|
|
478
|
+
return !!error && typeof error === "object" && "name" in error && error.name === "ConditionalCheckFailedException";
|
|
479
|
+
}
|
|
480
|
+
}
|
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.
|
|
3
|
+
"version": "3.1.1",
|
|
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"
|