lambder 4.3.1 → 4.4.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 +44 -6
- package/dist/client/LambderCaller.d.ts +75 -27
- package/dist/client/LambderCaller.js +18 -2
- package/dist/client.d.ts +1 -1
- package/dist/core/Lambder.d.ts +12 -9
- package/dist/core/Lambder.js +12 -8
- package/dist/core/LambderPublicFiles.d.ts +44 -12
- package/dist/core/LambderPublicFiles.js +60 -40
- package/dist/index.d.ts +7 -4
- package/dist/index.js +2 -1
- package/dist/session/LambderSessionController.d.ts +7 -0
- package/dist/session/LambderSessionController.js +10 -0
- package/dist/session/LambderSessionManager.d.ts +13 -19
- package/dist/session/LambderSessionManager.js +50 -18
- package/dist/stores/LambderDdbCache.d.ts +15 -4
- package/dist/stores/LambderDdbCache.js +49 -40
- package/dist/stores/LambderDdbCompression.d.ts +17 -0
- package/dist/stores/LambderDdbCompression.js +14 -7
- package/dist/stores/LambderDdbIdempotency.d.ts +19 -11
- package/dist/stores/LambderDdbIdempotency.js +17 -18
- package/dist/stores/LambderS3FileSource.d.ts +35 -0
- package/dist/stores/LambderS3FileSource.js +53 -0
- package/package.json +3 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { BatchWriteItemCommand, DeleteItemCommand, DynamoDBClient, GetItemCommand, PutItemCommand, QueryCommand, } from "@aws-sdk/client-dynamodb";
|
|
2
2
|
import { getCrypto } from "../shared/node-polyfills.js";
|
|
3
|
-
import { brotliCompressText, brotliRestoreText } from "./LambderDdbCompression.js";
|
|
3
|
+
import { brotliCompressText, brotliRestoreText, resolveCompressionOption, } from "./LambderDdbCompression.js";
|
|
4
4
|
import { LRUCache } from "lru-cache";
|
|
5
5
|
const DEFAULT_TTL_SECONDS = 365 * 24 * 60 * 60;
|
|
6
6
|
const DEFAULT_CHUNK_BYTES = 350 * 1024;
|
|
@@ -11,6 +11,8 @@ const META_SORT_KEY = "meta";
|
|
|
11
11
|
const LOCK_SORT_KEY = "lock";
|
|
12
12
|
const BATCH_WRITE_LIMIT = 25;
|
|
13
13
|
const MAX_BATCH_RETRIES = 8;
|
|
14
|
+
/** Every value compressed by default; see the `compression` option. */
|
|
15
|
+
const COMPRESSION_DEFAULTS = { minBytes: 0, quality: 5 };
|
|
14
16
|
// Node builtins are loaded lazily through node-polyfills so this module can
|
|
15
17
|
// sit in a frontend bundle's import graph (via the package root) without
|
|
16
18
|
// breaking; using the cache at runtime still requires Node. Brotli helpers
|
|
@@ -40,8 +42,10 @@ const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, mil
|
|
|
40
42
|
/**
|
|
41
43
|
* Persistent JSON cache backed by DynamoDB.
|
|
42
44
|
*
|
|
43
|
-
* Values are Brotli-compressed
|
|
44
|
-
*
|
|
45
|
+
* Values are Brotli-compressed by default (`compression` option; the manifest
|
|
46
|
+
* records each value's encoding, so the option can be switched on a live
|
|
47
|
+
* table). Values within the safe DynamoDB item budget are stored directly in
|
|
48
|
+
* the manifest for a single-request read; larger values are
|
|
45
49
|
* split into versioned binary chunks. A manifest is written only after every
|
|
46
50
|
* chunk succeeds, so readers see either the previous complete version or the
|
|
47
51
|
* new complete version. DynamoDB TTL is cleanup only; every read also checks
|
|
@@ -59,7 +63,7 @@ export class LambderDdbCache {
|
|
|
59
63
|
client;
|
|
60
64
|
defaultTtlSeconds;
|
|
61
65
|
chunkBytes;
|
|
62
|
-
|
|
66
|
+
compression;
|
|
63
67
|
maxValueBytes;
|
|
64
68
|
memory;
|
|
65
69
|
inFlight = new Map();
|
|
@@ -77,17 +81,14 @@ export class LambderDdbCache {
|
|
|
77
81
|
if (this.chunkBytes > MAX_SAFE_CHUNK_BYTES) {
|
|
78
82
|
throw new Error(`chunkBytes must not exceed ${MAX_SAFE_CHUNK_BYTES}`);
|
|
79
83
|
}
|
|
80
|
-
this.
|
|
81
|
-
if (!Number.isInteger(this.compressionQuality) || this.compressionQuality < 0 || this.compressionQuality > 11) {
|
|
82
|
-
throw new Error("compressionQuality must be an integer from 0 to 11");
|
|
83
|
-
}
|
|
84
|
+
this.compression = resolveCompressionOption(options.compression, COMPRESSION_DEFAULTS);
|
|
84
85
|
this.maxValueBytes = positiveInteger(options.maxValueBytes ?? DEFAULT_MAX_VALUE_BYTES, "maxValueBytes");
|
|
85
86
|
const memoryMaxBytes = options.memoryMaxBytes ?? DEFAULT_MEMORY_BYTES;
|
|
86
87
|
this.memory = memoryMaxBytes === 0
|
|
87
88
|
? null
|
|
88
89
|
: new LRUCache({
|
|
89
90
|
maxSize: positiveInteger(memoryMaxBytes, "memoryMaxBytes"),
|
|
90
|
-
sizeCalculation: (entry) => entry.
|
|
91
|
+
sizeCalculation: (entry) => entry.stored.length,
|
|
91
92
|
});
|
|
92
93
|
this.client = options.client ?? new DynamoDBClient({ region: options.region ?? "us-east-1" });
|
|
93
94
|
}
|
|
@@ -97,7 +98,7 @@ export class LambderDdbCache {
|
|
|
97
98
|
const nowSeconds = this.nowSeconds();
|
|
98
99
|
if (cached && cached.expiresAt > nowSeconds) {
|
|
99
100
|
try {
|
|
100
|
-
return JSON.parse(await
|
|
101
|
+
return JSON.parse(await this.decode(cached.stored, cached.encoding, cached.uncompressedBytes));
|
|
101
102
|
}
|
|
102
103
|
catch {
|
|
103
104
|
// Fall through to DynamoDB; the in-memory copy is disposable.
|
|
@@ -110,16 +111,16 @@ export class LambderDdbCache {
|
|
|
110
111
|
if (!manifest || manifest.expiresAt <= nowSeconds)
|
|
111
112
|
return undefined;
|
|
112
113
|
try {
|
|
113
|
-
const
|
|
114
|
-
if (
|
|
115
|
-
throw new Error("
|
|
114
|
+
const stored = manifest.inlineData ?? await this.readChunks(pk, manifest);
|
|
115
|
+
if (stored.length !== manifest.storedBytes) {
|
|
116
|
+
throw new Error("stored byte length does not match manifest");
|
|
116
117
|
}
|
|
117
|
-
if (await sha256(
|
|
118
|
-
throw new Error("
|
|
118
|
+
if (await sha256(stored) !== manifest.checksum) {
|
|
119
|
+
throw new Error("stored checksum does not match manifest");
|
|
119
120
|
}
|
|
120
|
-
const json = await
|
|
121
|
+
const json = await this.decode(stored, manifest.encoding, manifest.uncompressedBytes);
|
|
121
122
|
const parsed = JSON.parse(json);
|
|
122
|
-
this.remember(normalizedKey,
|
|
123
|
+
this.remember(normalizedKey, stored, manifest.encoding, manifest.uncompressedBytes, manifest.expiresAt);
|
|
123
124
|
return parsed;
|
|
124
125
|
}
|
|
125
126
|
catch (error) {
|
|
@@ -149,18 +150,20 @@ export class LambderDdbCache {
|
|
|
149
150
|
if (input.length > this.maxValueBytes) {
|
|
150
151
|
throw new Error(`Cache value exceeds maxValueBytes (${input.length} > ${this.maxValueBytes})`);
|
|
151
152
|
}
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
153
|
+
const brotli = this.compression && input.length >= this.compression.minBytes ? this.compression : null;
|
|
154
|
+
const encoding = brotli ? "br" : "identity";
|
|
155
|
+
const stored = brotli ? await brotliCompressText(input, brotli.quality) : input;
|
|
156
|
+
if (stored.length > this.maxValueBytes) {
|
|
157
|
+
throw new Error(`Stored cache value exceeds maxValueBytes (${stored.length} > ${this.maxValueBytes})`);
|
|
155
158
|
}
|
|
156
159
|
const pk = await this.partitionKey(normalizedKey);
|
|
157
160
|
const version = `${Date.now().toString(36)}-${await randomUUID()}`;
|
|
158
161
|
const expiresAt = this.nowSeconds() + ttlSeconds;
|
|
159
162
|
const chunks = [];
|
|
160
|
-
const inline =
|
|
163
|
+
const inline = stored.length <= this.chunkBytes;
|
|
161
164
|
if (!inline) {
|
|
162
|
-
for (let offset = 0; offset <
|
|
163
|
-
chunks.push(
|
|
165
|
+
for (let offset = 0; offset < stored.length; offset += this.chunkBytes) {
|
|
166
|
+
chunks.push(stored.subarray(offset, offset + this.chunkBytes));
|
|
164
167
|
}
|
|
165
168
|
}
|
|
166
169
|
const writes = chunks.map((chunk, index) => ({
|
|
@@ -181,16 +184,16 @@ export class LambderDdbCache {
|
|
|
181
184
|
sk: { S: META_SORT_KEY },
|
|
182
185
|
version: { S: version },
|
|
183
186
|
chunkCount: { N: String(chunks.length) },
|
|
184
|
-
|
|
187
|
+
storedBytes: { N: String(stored.length) },
|
|
185
188
|
uncompressedBytes: { N: String(input.length) },
|
|
186
|
-
checksum: { S: await sha256(
|
|
187
|
-
encoding: { S:
|
|
189
|
+
checksum: { S: await sha256(stored) },
|
|
190
|
+
encoding: { S: encoding },
|
|
188
191
|
createdAt: { N: String(this.nowSeconds()) },
|
|
189
192
|
expiresAt: { N: String(expiresAt) },
|
|
190
|
-
...(inline ? { data: { B:
|
|
193
|
+
...(inline ? { data: { B: stored } } : {}),
|
|
191
194
|
},
|
|
192
195
|
}));
|
|
193
|
-
this.remember(normalizedKey,
|
|
196
|
+
this.remember(normalizedKey, stored, encoding, input.length, expiresAt);
|
|
194
197
|
}
|
|
195
198
|
async delete(key) {
|
|
196
199
|
const normalizedKey = this.normalizeKey(key);
|
|
@@ -344,37 +347,39 @@ export class LambderDdbCache {
|
|
|
344
347
|
const version = item.version?.S;
|
|
345
348
|
const encoding = item.encoding?.S;
|
|
346
349
|
const chunkCount = Number(item.chunkCount?.N);
|
|
347
|
-
const
|
|
350
|
+
const storedBytes = Number(item.storedBytes?.N);
|
|
348
351
|
const uncompressedBytes = Number(item.uncompressedBytes?.N);
|
|
349
352
|
const expiresAt = Number(item.expiresAt?.N);
|
|
350
353
|
const checksum = item.checksum?.S;
|
|
351
354
|
const inlineData = item.data?.B == null ? undefined : Buffer.from(item.data.B);
|
|
352
355
|
const validInline = inlineData !== undefined &&
|
|
353
356
|
chunkCount === 0 &&
|
|
354
|
-
inlineData.length ===
|
|
355
|
-
|
|
357
|
+
inlineData.length === storedBytes &&
|
|
358
|
+
storedBytes <= this.chunkBytes;
|
|
356
359
|
const validChunks = inlineData === undefined &&
|
|
357
360
|
chunkCount > 0 &&
|
|
358
|
-
chunkCount === Math.ceil(
|
|
361
|
+
chunkCount === Math.ceil(storedBytes / this.chunkBytes);
|
|
359
362
|
if (!version ||
|
|
360
|
-
encoding !== "br" ||
|
|
363
|
+
(encoding !== "br" && encoding !== "identity") ||
|
|
361
364
|
!checksum ||
|
|
362
365
|
!Number.isSafeInteger(chunkCount) ||
|
|
363
366
|
chunkCount < 0 ||
|
|
364
|
-
!Number.isSafeInteger(
|
|
365
|
-
|
|
366
|
-
|
|
367
|
+
!Number.isSafeInteger(storedBytes) ||
|
|
368
|
+
storedBytes < 0 ||
|
|
369
|
+
storedBytes > this.maxValueBytes ||
|
|
367
370
|
!Number.isSafeInteger(uncompressedBytes) ||
|
|
368
371
|
uncompressedBytes < 0 ||
|
|
369
372
|
uncompressedBytes > this.maxValueBytes ||
|
|
373
|
+
(encoding === "identity" && storedBytes !== uncompressedBytes) ||
|
|
370
374
|
!Number.isSafeInteger(expiresAt) ||
|
|
371
375
|
(!validInline && !validChunks)) {
|
|
372
376
|
return undefined;
|
|
373
377
|
}
|
|
374
378
|
return {
|
|
375
379
|
version,
|
|
380
|
+
encoding,
|
|
376
381
|
chunkCount,
|
|
377
|
-
|
|
382
|
+
storedBytes,
|
|
378
383
|
uncompressedBytes,
|
|
379
384
|
checksum,
|
|
380
385
|
expiresAt,
|
|
@@ -411,7 +416,7 @@ export class LambderDdbCache {
|
|
|
411
416
|
throw new Error(`DynamoDB cache entry has an invalid chunk index at ${index}`);
|
|
412
417
|
}
|
|
413
418
|
}
|
|
414
|
-
return Buffer.concat(chunks.map((chunk) => chunk.data), manifest.
|
|
419
|
+
return Buffer.concat(chunks.map((chunk) => chunk.data), manifest.storedBytes);
|
|
415
420
|
}
|
|
416
421
|
async invalidateManifest(pk, version) {
|
|
417
422
|
try {
|
|
@@ -445,13 +450,17 @@ export class LambderDdbCache {
|
|
|
445
450
|
}
|
|
446
451
|
}
|
|
447
452
|
}
|
|
448
|
-
|
|
453
|
+
/** The JSON text of a stored payload. */
|
|
454
|
+
async decode(stored, encoding, uncompressedBytes) {
|
|
455
|
+
return encoding === "br" ? await brotliRestoreText(stored, uncompressedBytes) : stored.toString("utf8");
|
|
456
|
+
}
|
|
457
|
+
remember(key, stored, encoding, uncompressedBytes, expiresAt) {
|
|
449
458
|
if (!this.memory)
|
|
450
459
|
return;
|
|
451
460
|
const ttl = expiresAt * 1000 - Date.now();
|
|
452
461
|
if (ttl <= 0)
|
|
453
462
|
return;
|
|
454
|
-
this.memory.set(key, {
|
|
463
|
+
this.memory.set(key, { stored, encoding, uncompressedBytes, expiresAt }, { ttl });
|
|
455
464
|
}
|
|
456
465
|
normalizeKey(key) {
|
|
457
466
|
if (typeof key !== "string" || !key.trim())
|
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The compression option every store shares. `true` is on with the store's
|
|
3
|
+
* defaults, `false` is off, an object overrides the defaults: `minBytes` is
|
|
4
|
+
* the UTF-8 size from which a value is stored compressed (0: always),
|
|
5
|
+
* `quality` is Brotli 0-11. Values below minBytes are stored plain, and a
|
|
6
|
+
* store reads records of either shape, so the option can be switched on or
|
|
7
|
+
* off on a live table: records written under the other setting keep
|
|
8
|
+
* reading, and each is rewritten in the current shape on its next write.
|
|
9
|
+
*/
|
|
10
|
+
export type LambderCompressionConfig = {
|
|
11
|
+
minBytes?: number;
|
|
12
|
+
quality?: number;
|
|
13
|
+
};
|
|
14
|
+
export type LambderCompressionOption = boolean | LambderCompressionConfig;
|
|
15
|
+
export type LambderCompressionSettings = Required<LambderCompressionConfig>;
|
|
16
|
+
/** Resolves a store's compression option against its defaults: null when off. */
|
|
17
|
+
export declare const resolveCompressionOption: (option: LambderCompressionOption | undefined, defaults: LambderCompressionSettings) => LambderCompressionSettings | null;
|
|
1
18
|
export declare const brotliCompressText: (input: Buffer, quality: number) => Promise<Buffer>;
|
|
2
19
|
/**
|
|
3
20
|
* Restores text stored as Brotli bytes beside its declared UTF-8 byte length.
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { getZlib } from "../shared/node-polyfills.js";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
2
|
+
/** Resolves a store's compression option against its defaults: null when off. */
|
|
3
|
+
export const resolveCompressionOption = (option, defaults) => {
|
|
4
|
+
if (option === false)
|
|
5
|
+
return null;
|
|
6
|
+
const config = option === true || option === undefined ? {} : option;
|
|
7
|
+
const settings = { minBytes: config.minBytes ?? defaults.minBytes, quality: config.quality ?? defaults.quality };
|
|
8
|
+
if (!Number.isSafeInteger(settings.minBytes) || settings.minBytes < 0) {
|
|
9
|
+
throw new Error("compression.minBytes must be a non-negative integer");
|
|
10
|
+
}
|
|
11
|
+
if (!Number.isInteger(settings.quality) || settings.quality < 0 || settings.quality > 11) {
|
|
12
|
+
throw new Error("compression.quality must be an integer from 0 to 11");
|
|
13
|
+
}
|
|
14
|
+
return settings;
|
|
15
|
+
};
|
|
9
16
|
const requireZlib = async () => {
|
|
10
17
|
const zlib = await getZlib();
|
|
11
18
|
if (!zlib)
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
2
|
+
import { type LambderCompressionOption } from "./LambderDdbCompression.js";
|
|
2
3
|
export interface LambderDdbIdempotencyOptions {
|
|
3
4
|
tableName: string;
|
|
4
5
|
region?: string;
|
|
5
6
|
/** Partition key prefix, keeps records separated from other systems in a shared table. Default: "IDEM". */
|
|
6
7
|
keyPrefix?: string;
|
|
7
|
-
/**
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Brotli compression of stored bodies. `true` (the default) is
|
|
10
|
+
* `{ minBytes: 1024, quality: 5 }`; `false` stores every body plain; an
|
|
11
|
+
* object overrides the defaults. Records of either shape read back, so
|
|
12
|
+
* it can be switched on or off on a live table.
|
|
13
|
+
*/
|
|
14
|
+
compression?: LambderCompressionOption;
|
|
9
15
|
client?: DynamoDBClient;
|
|
10
16
|
}
|
|
11
17
|
export type LambderIdempotencyDoneRecord = {
|
|
@@ -35,10 +41,11 @@ export type LambderIdempotencyBeginResult = {
|
|
|
35
41
|
* and loses the scope to a retry can no longer overwrite or delete the
|
|
36
42
|
* retry's claim (both settle calls become silent no-ops instead).
|
|
37
43
|
*
|
|
38
|
-
* Stored bodies
|
|
39
|
-
* LambderDdbCache): the bodies are JSON
|
|
40
|
-
* 5-10x, which cuts DynamoDB write units
|
|
41
|
-
* item budget instead of skipping replay
|
|
44
|
+
* Stored bodies are Brotli-compressed from 1KB by default (same scheme as
|
|
45
|
+
* LambderDdbCache, see the `compression` option): the bodies are JSON
|
|
46
|
+
* envelopes that typically shrink 5-10x, which cuts DynamoDB write units
|
|
47
|
+
* and lets large responses fit the item budget instead of skipping replay
|
|
48
|
+
* storage.
|
|
42
49
|
*
|
|
43
50
|
* Table shape: string hash key `pk`, string range key `sk`, TTL on
|
|
44
51
|
* `expiresAt`. Items are prefixed `IDEM#` by default, so the table can be
|
|
@@ -48,7 +55,7 @@ export type LambderIdempotencyBeginResult = {
|
|
|
48
55
|
export declare class LambderDdbIdempotency {
|
|
49
56
|
readonly tableName: string;
|
|
50
57
|
readonly keyPrefix: string;
|
|
51
|
-
private readonly
|
|
58
|
+
private readonly compression;
|
|
52
59
|
private readonly client;
|
|
53
60
|
constructor(options: LambderDdbIdempotencyOptions);
|
|
54
61
|
private itemKey;
|
|
@@ -74,10 +81,11 @@ export declare class LambderDdbIdempotency {
|
|
|
74
81
|
}): Promise<LambderIdempotencyBeginResult>;
|
|
75
82
|
/**
|
|
76
83
|
* Store the response for replays, overwriting the pending claim. Bodies
|
|
77
|
-
*
|
|
78
|
-
* JSON envelopes, which typically shrink 5-10x), cutting
|
|
79
|
-
* units and letting large responses fit the item budget;
|
|
80
|
-
* stay plain.
|
|
84
|
+
* from the compression option's minBytes are stored Brotli-compressed
|
|
85
|
+
* (they are JSON envelopes, which typically shrink 5-10x), cutting
|
|
86
|
+
* DynamoDB write units and letting large responses fit the item budget;
|
|
87
|
+
* smaller bodies, or all of them with compression off, stay plain.
|
|
88
|
+
* Returns:
|
|
81
89
|
*
|
|
82
90
|
* - "stored": the record is in place and will replay.
|
|
83
91
|
* - "too-large": even compressed, the body exceeds the item budget;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import crypto from "crypto";
|
|
2
2
|
import { DynamoDBClient, PutItemCommand, GetItemCommand, DeleteItemCommand, } from "@aws-sdk/client-dynamodb";
|
|
3
|
-
import { brotliCompressText, brotliRestoreText } from "./LambderDdbCompression.js";
|
|
4
|
-
/** Bodies
|
|
5
|
-
const
|
|
3
|
+
import { brotliCompressText, brotliRestoreText, resolveCompressionOption, } from "./LambderDdbCompression.js";
|
|
4
|
+
/** Bodies of 1KB or more are stored Brotli-compressed by default; smaller ones stay plain. */
|
|
5
|
+
const COMPRESSION_DEFAULTS = { minBytes: 1024, quality: 5 };
|
|
6
6
|
/**
|
|
7
7
|
* Stored-body budget inside DynamoDB's 400KB item limit (headers, keys and
|
|
8
8
|
* attributes need headroom). Applies to the bytes actually stored, so a
|
|
@@ -22,10 +22,11 @@ const MAX_STORED_BODY_BYTES = 350_000;
|
|
|
22
22
|
* and loses the scope to a retry can no longer overwrite or delete the
|
|
23
23
|
* retry's claim (both settle calls become silent no-ops instead).
|
|
24
24
|
*
|
|
25
|
-
* Stored bodies
|
|
26
|
-
* LambderDdbCache): the bodies are JSON
|
|
27
|
-
* 5-10x, which cuts DynamoDB write units
|
|
28
|
-
* item budget instead of skipping replay
|
|
25
|
+
* Stored bodies are Brotli-compressed from 1KB by default (same scheme as
|
|
26
|
+
* LambderDdbCache, see the `compression` option): the bodies are JSON
|
|
27
|
+
* envelopes that typically shrink 5-10x, which cuts DynamoDB write units
|
|
28
|
+
* and lets large responses fit the item budget instead of skipping replay
|
|
29
|
+
* storage.
|
|
29
30
|
*
|
|
30
31
|
* Table shape: string hash key `pk`, string range key `sk`, TTL on
|
|
31
32
|
* `expiresAt`. Items are prefixed `IDEM#` by default, so the table can be
|
|
@@ -35,17 +36,14 @@ const MAX_STORED_BODY_BYTES = 350_000;
|
|
|
35
36
|
export class LambderDdbIdempotency {
|
|
36
37
|
tableName;
|
|
37
38
|
keyPrefix;
|
|
38
|
-
|
|
39
|
+
compression;
|
|
39
40
|
client;
|
|
40
41
|
constructor(options) {
|
|
41
42
|
if (!options.tableName.trim())
|
|
42
43
|
throw new Error("tableName is required");
|
|
43
44
|
this.tableName = options.tableName;
|
|
44
45
|
this.keyPrefix = options.keyPrefix ?? "IDEM";
|
|
45
|
-
this.
|
|
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
|
-
}
|
|
46
|
+
this.compression = resolveCompressionOption(options.compression, COMPRESSION_DEFAULTS);
|
|
49
47
|
this.client = options.client ?? new DynamoDBClient(options.region ? { region: options.region } : {});
|
|
50
48
|
}
|
|
51
49
|
itemKey(scopeKey) {
|
|
@@ -142,10 +140,11 @@ export class LambderDdbIdempotency {
|
|
|
142
140
|
}
|
|
143
141
|
/**
|
|
144
142
|
* Store the response for replays, overwriting the pending claim. Bodies
|
|
145
|
-
*
|
|
146
|
-
* JSON envelopes, which typically shrink 5-10x), cutting
|
|
147
|
-
* units and letting large responses fit the item budget;
|
|
148
|
-
* stay plain.
|
|
143
|
+
* from the compression option's minBytes are stored Brotli-compressed
|
|
144
|
+
* (they are JSON envelopes, which typically shrink 5-10x), cutting
|
|
145
|
+
* DynamoDB write units and letting large responses fit the item budget;
|
|
146
|
+
* smaller bodies, or all of them with compression off, stay plain.
|
|
147
|
+
* Returns:
|
|
149
148
|
*
|
|
150
149
|
* - "stored": the record is in place and will replay.
|
|
151
150
|
* - "too-large": even compressed, the body exceeds the item budget;
|
|
@@ -157,8 +156,8 @@ export class LambderDdbIdempotency {
|
|
|
157
156
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
158
157
|
const rawBody = Buffer.from(body, "utf8");
|
|
159
158
|
let bodyAttributes;
|
|
160
|
-
if (rawBody.byteLength >=
|
|
161
|
-
const compressed = await brotliCompressText(rawBody, this.
|
|
159
|
+
if (this.compression && rawBody.byteLength >= this.compression.minBytes) {
|
|
160
|
+
const compressed = await brotliCompressText(rawBody, this.compression.quality);
|
|
162
161
|
if (compressed.byteLength > MAX_STORED_BODY_BYTES)
|
|
163
162
|
return "too-large";
|
|
164
163
|
// bodyBytes bounds and verifies decompression on read.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { S3Client, S3ClientConfig } from "@aws-sdk/client-s3";
|
|
2
|
+
import type { LambderPublicFile, LambderPublicFileSource } from "../core/LambderPublicFiles.js";
|
|
3
|
+
export type LambderS3FileSourceOptions = {
|
|
4
|
+
bucket: string;
|
|
5
|
+
/** Literal key prefix the relative path is appended to, so include the trailing slash: "web/v42/". Default: none. */
|
|
6
|
+
prefix?: string;
|
|
7
|
+
/** A ready client, e.g. one shared with the rest of the app. */
|
|
8
|
+
client?: S3Client;
|
|
9
|
+
/**
|
|
10
|
+
* Otherwise the client is created from this on first read: `{ region }`
|
|
11
|
+
* for S3; for Cloudflare R2 or another S3-compatible store,
|
|
12
|
+
* `{ region: "auto", endpoint, credentials }`.
|
|
13
|
+
*/
|
|
14
|
+
clientConfig?: S3ClientConfig;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Files from an S3 bucket, or any S3-compatible store such as Cloudflare
|
|
18
|
+
* R2 (pass its endpoint in clientConfig). Needs @aws-sdk/client-s3, an
|
|
19
|
+
* optional peer dependency loaded on first read, so apps that serve from a
|
|
20
|
+
* folder never load it. A missing object reads as null and the request
|
|
21
|
+
* falls through; grant s3:ListBucket besides s3:GetObject, otherwise S3
|
|
22
|
+
* answers a missing key with AccessDenied, which propagates as an error.
|
|
23
|
+
* An object's Content-Type is used unless it is a generic octet-stream, in
|
|
24
|
+
* which case the extension decides, as for local files.
|
|
25
|
+
*/
|
|
26
|
+
export declare class LambderS3FileSource implements LambderPublicFileSource {
|
|
27
|
+
private readonly bucket;
|
|
28
|
+
private readonly prefix;
|
|
29
|
+
private readonly clientConfig;
|
|
30
|
+
private client;
|
|
31
|
+
private sdk;
|
|
32
|
+
constructor({ bucket, prefix, client, clientConfig }: LambderS3FileSourceOptions);
|
|
33
|
+
private loadSdk;
|
|
34
|
+
read(relativePath: string): Promise<LambderPublicFile | null>;
|
|
35
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files from an S3 bucket, or any S3-compatible store such as Cloudflare
|
|
3
|
+
* R2 (pass its endpoint in clientConfig). Needs @aws-sdk/client-s3, an
|
|
4
|
+
* optional peer dependency loaded on first read, so apps that serve from a
|
|
5
|
+
* folder never load it. A missing object reads as null and the request
|
|
6
|
+
* falls through; grant s3:ListBucket besides s3:GetObject, otherwise S3
|
|
7
|
+
* answers a missing key with AccessDenied, which propagates as an error.
|
|
8
|
+
* An object's Content-Type is used unless it is a generic octet-stream, in
|
|
9
|
+
* which case the extension decides, as for local files.
|
|
10
|
+
*/
|
|
11
|
+
export class LambderS3FileSource {
|
|
12
|
+
bucket;
|
|
13
|
+
prefix;
|
|
14
|
+
clientConfig;
|
|
15
|
+
client;
|
|
16
|
+
sdk;
|
|
17
|
+
constructor({ bucket, prefix = "", client, clientConfig }) {
|
|
18
|
+
if (!bucket.trim())
|
|
19
|
+
throw new Error("bucket is required");
|
|
20
|
+
this.bucket = bucket;
|
|
21
|
+
this.prefix = prefix;
|
|
22
|
+
this.client = client;
|
|
23
|
+
this.clientConfig = clientConfig;
|
|
24
|
+
}
|
|
25
|
+
loadSdk() {
|
|
26
|
+
if (!this.sdk) {
|
|
27
|
+
this.sdk = import("@aws-sdk/client-s3").catch(() => {
|
|
28
|
+
throw new Error("LambderS3FileSource requires @aws-sdk/client-s3: npm install @aws-sdk/client-s3");
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return this.sdk;
|
|
32
|
+
}
|
|
33
|
+
async read(relativePath) {
|
|
34
|
+
const { S3Client, GetObjectCommand } = await this.loadSdk();
|
|
35
|
+
if (!this.client)
|
|
36
|
+
this.client = new S3Client(this.clientConfig ?? {});
|
|
37
|
+
let output;
|
|
38
|
+
try {
|
|
39
|
+
output = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: `${this.prefix}${relativePath}` }));
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
const name = err.name;
|
|
43
|
+
if (name === "NoSuchKey" || name === "NotFound")
|
|
44
|
+
return null;
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
if (!output.Body)
|
|
48
|
+
return null;
|
|
49
|
+
const body = Buffer.from(await output.Body.transformToByteArray());
|
|
50
|
+
const contentType = output.ContentType;
|
|
51
|
+
return contentType && !contentType.endsWith("octet-stream") ? { body, mimeType: contentType } : { body };
|
|
52
|
+
}
|
|
53
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lambder",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.4.1",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
"zod": "^4.1.12"
|
|
62
62
|
},
|
|
63
63
|
"peerDependencies": {
|
|
64
|
+
"@aws-sdk/client-s3": "^3.574.0",
|
|
64
65
|
"msw": "^2.0.0"
|
|
65
66
|
},
|
|
66
67
|
"peerDependenciesMeta": {
|
|
@@ -69,6 +70,7 @@
|
|
|
69
70
|
}
|
|
70
71
|
},
|
|
71
72
|
"devDependencies": {
|
|
73
|
+
"@aws-sdk/client-s3": "^3.1127.0",
|
|
72
74
|
"@types/aws-lambda": "^8.10.136",
|
|
73
75
|
"@types/cookie": "^0.6.0",
|
|
74
76
|
"@types/js-cookie": "^3.0.6",
|