lambder 2.0.18 → 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.
Files changed (69) hide show
  1. package/Readme.md +184 -41
  2. package/dist/Lambder.d.ts +154 -46
  3. package/dist/Lambder.js +312 -166
  4. package/dist/LambderCaller.js +6 -3
  5. package/dist/LambderContext.d.ts +20 -9
  6. package/dist/LambderContext.js +57 -17
  7. package/dist/LambderCors.d.ts +12 -0
  8. package/dist/LambderCors.js +30 -0
  9. package/dist/LambderDdbCache.d.ts +65 -0
  10. package/dist/LambderDdbCache.js +480 -0
  11. package/dist/LambderHtml.d.ts +33 -0
  12. package/dist/LambderHtml.js +62 -0
  13. package/dist/LambderMSW.d.ts +16 -1
  14. package/dist/LambderMSW.js +5 -9
  15. package/dist/LambderPublicFiles.d.ts +47 -0
  16. package/dist/LambderPublicFiles.js +108 -0
  17. package/dist/LambderResolver.d.ts +30 -31
  18. package/dist/LambderResolver.js +29 -43
  19. package/dist/LambderResponse.d.ts +71 -0
  20. package/dist/LambderResponse.js +196 -0
  21. package/dist/LambderResponseBuilder.d.ts +58 -33
  22. package/dist/LambderResponseBuilder.js +114 -167
  23. package/dist/LambderRouting.d.ts +23 -0
  24. package/dist/LambderRouting.js +67 -0
  25. package/dist/LambderSessionController.d.ts +13 -1
  26. package/dist/LambderSessionController.js +33 -10
  27. package/dist/LambderSessionManager.d.ts +3 -1
  28. package/dist/LambderSessionManager.js +15 -6
  29. package/dist/LambderTemplatingEngine.d.ts +87 -0
  30. package/dist/LambderTemplatingEngine.js +156 -0
  31. package/dist/index.d.ts +16 -2
  32. package/dist/index.js +12 -1
  33. package/dist/node-polyfills.d.ts +4 -2
  34. package/dist/node-polyfills.js +28 -0
  35. package/package.json +8 -5
  36. package/.eslintrc.cjs +0 -26
  37. package/.vscode/settings.json +0 -26
  38. package/deploy +0 -22
  39. package/dist/LambderUtils.d.ts +0 -10
  40. package/dist/LambderUtils.js +0 -70
  41. package/docs/DYNAMODB_SETUP.md +0 -96
  42. package/docs/LAMBDER_MSW.md +0 -409
  43. package/docs/TYPE_SAFE_QUICK_START.md +0 -77
  44. package/examples/msw-testing-example.ts +0 -280
  45. package/examples/secure-session-example.ts +0 -207
  46. package/examples/zod-chained-api-example.ts +0 -63
  47. package/src/Lambder.ts +0 -430
  48. package/src/LambderApiContract.ts +0 -20
  49. package/src/LambderCaller.ts +0 -238
  50. package/src/LambderContext.ts +0 -78
  51. package/src/LambderMSW.ts +0 -180
  52. package/src/LambderResolver.ts +0 -101
  53. package/src/LambderResponseBuilder.ts +0 -332
  54. package/src/LambderSessionController.ts +0 -114
  55. package/src/LambderSessionManager.ts +0 -217
  56. package/src/LambderUtils.ts +0 -75
  57. package/src/index.ts +0 -17
  58. package/src/node-polyfills.ts +0 -27
  59. package/tests/error-handling.test.ts +0 -585
  60. package/tests/file-serving.test.ts +0 -194
  61. package/tests/fixtures/public/index.html +0 -1
  62. package/tests/fixtures/public/main.css +0 -1
  63. package/tests/hooks.test.ts +0 -561
  64. package/tests/output-type-runtime.test.ts +0 -381
  65. package/tests/redirect.test.ts +0 -88
  66. package/tests/routes.test.ts +0 -543
  67. package/tests/session.test.ts +0 -1083
  68. package/tests/use-plugin.test.ts +0 -460
  69. package/tsconfig.json +0 -24
@@ -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
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Type-safe templating via tagged template literals: interpolated values are
3
+ * HTML-escaped by default, so templates are XSS-safe and fully type-checked by
4
+ * TypeScript (no untyped template-locals bag like EJS).
5
+ *
6
+ * - strings/numbers are escaped
7
+ * - null/undefined/booleans render as "" (enables `${cond && html`...`}`)
8
+ * - arrays are flattened (`${items.map((i) => html`<li>${i}</li>`)}`)
9
+ * - nested html`...` fragments are inserted verbatim (no double escaping)
10
+ * - raw(value) marks a trusted string as safe; never pass user input to it
11
+ *
12
+ * The same escaping rules are valid XML, so `xml` is an alias for sitemaps etc.
13
+ */
14
+ export declare class LambderSafeHtml {
15
+ readonly value: string;
16
+ constructor(value: string);
17
+ toString(): string;
18
+ }
19
+ export type LambderHtmlValue = string | number | boolean | null | undefined | LambderSafeHtml | LambderHtmlValue[];
20
+ export declare const escapeHtml: (value: string) => string;
21
+ /** Serialize any LambderHtmlValue to a string (escaped unless marked safe). */
22
+ export declare const renderHtmlValue: (value: LambderHtmlValue) => string;
23
+ export declare const html: (strings: TemplateStringsArray, ...values: LambderHtmlValue[]) => LambderSafeHtml;
24
+ /** Alias of html for XML documents (identical, XML-valid escaping). */
25
+ export declare const xml: (strings: TemplateStringsArray, ...values: LambderHtmlValue[]) => LambderSafeHtml;
26
+ /** Mark a trusted string as safe (inserted without escaping). Never pass user input. */
27
+ export declare const raw: (value: string) => LambderSafeHtml;
28
+ /**
29
+ * Server-preloaded state as <script type="application/json" id="..."> so an SPA
30
+ * can hydrate without a first fetch. Escaped so the payload can't break out of
31
+ * the script element. Read with JSON.parse(document.getElementById(id).textContent).
32
+ */
33
+ export declare const jsonScript: (id: string, data: unknown) => LambderSafeHtml;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Type-safe templating via tagged template literals: interpolated values are
3
+ * HTML-escaped by default, so templates are XSS-safe and fully type-checked by
4
+ * TypeScript (no untyped template-locals bag like EJS).
5
+ *
6
+ * - strings/numbers are escaped
7
+ * - null/undefined/booleans render as "" (enables `${cond && html`...`}`)
8
+ * - arrays are flattened (`${items.map((i) => html`<li>${i}</li>`)}`)
9
+ * - nested html`...` fragments are inserted verbatim (no double escaping)
10
+ * - raw(value) marks a trusted string as safe; never pass user input to it
11
+ *
12
+ * The same escaping rules are valid XML, so `xml` is an alias for sitemaps etc.
13
+ */
14
+ export class LambderSafeHtml {
15
+ value;
16
+ constructor(value) { this.value = value; }
17
+ toString() { return this.value; }
18
+ }
19
+ export const escapeHtml = (value) => value
20
+ .replace(/&/g, "&amp;")
21
+ .replace(/</g, "&lt;")
22
+ .replace(/>/g, "&gt;")
23
+ .replace(/"/g, "&quot;")
24
+ .replace(/'/g, "&#39;")
25
+ .replace(/`/g, "&#96;");
26
+ /** Serialize any LambderHtmlValue to a string (escaped unless marked safe). */
27
+ export const renderHtmlValue = (value) => {
28
+ if (value === null || value === undefined || typeof value === "boolean")
29
+ return "";
30
+ if (value instanceof LambderSafeHtml)
31
+ return value.value;
32
+ if (Array.isArray(value))
33
+ return value.map(renderHtmlValue).join("");
34
+ if (typeof value === "number")
35
+ return String(value);
36
+ return escapeHtml(value);
37
+ };
38
+ export const html = (strings, ...values) => {
39
+ let out = "";
40
+ for (let i = 0; i < strings.length; i++) {
41
+ out += strings[i];
42
+ if (i < values.length)
43
+ out += renderHtmlValue(values[i]);
44
+ }
45
+ return new LambderSafeHtml(out);
46
+ };
47
+ /** Alias of html for XML documents (identical, XML-valid escaping). */
48
+ export const xml = html;
49
+ /** Mark a trusted string as safe (inserted without escaping). Never pass user input. */
50
+ export const raw = (value) => new LambderSafeHtml(value);
51
+ /**
52
+ * Server-preloaded state as <script type="application/json" id="..."> so an SPA
53
+ * can hydrate without a first fetch. Escaped so the payload can't break out of
54
+ * the script element. Read with JSON.parse(document.getElementById(id).textContent).
55
+ */
56
+ export const jsonScript = (id, data) => {
57
+ const json = JSON.stringify(data)
58
+ .replace(/</g, "\\u003c")
59
+ .replace(/\u2028/g, "\\u2028")
60
+ .replace(/\u2029/g, "\\u2029");
61
+ return new LambderSafeHtml(`<script type="application/json" id="${escapeHtml(id)}">${json}</script>`);
62
+ };
@@ -1,5 +1,18 @@
1
1
  import type { ApiContractShape } from './LambderApiContract';
2
2
  type RequestHandler = any;
3
+ /** The parts of the msw module LambderMSW uses: `import { http, HttpResponse } from "msw"`. */
4
+ export type LambderMswModule = {
5
+ http: {
6
+ post: (path: string, resolver: (info: {
7
+ request: Request;
8
+ }) => any) => any;
9
+ };
10
+ HttpResponse: {
11
+ json: (body: any, init?: {
12
+ status?: number;
13
+ }) => any;
14
+ };
15
+ };
3
16
  type MockApiOptions = {
4
17
  versionExpired?: boolean;
5
18
  sessionExpired?: boolean;
@@ -14,9 +27,11 @@ export default class LambderMSW<TContract extends ApiContractShape = any> {
14
27
  private apiVersion?;
15
28
  private http;
16
29
  private HttpResponse;
17
- constructor({ apiPath, apiVersion, }: {
30
+ constructor({ apiPath, apiVersion, msw, }: {
18
31
  apiPath: string;
19
32
  apiVersion?: string;
33
+ /** Pass the msw module: `import * as msw from "msw"` (ESM-safe; no hidden require). */
34
+ msw: LambderMswModule;
20
35
  });
21
36
  /**
22
37
  * Mock an API endpoint with MSW
@@ -3,18 +3,14 @@ export default class LambderMSW {
3
3
  apiVersion;
4
4
  http;
5
5
  HttpResponse;
6
- constructor({ apiPath, apiVersion, }) {
6
+ constructor({ apiPath, apiVersion, msw, }) {
7
7
  this.apiPath = apiPath;
8
8
  this.apiVersion = apiVersion;
9
- // Dynamically import MSW - it needs to be installed by the user
10
- try {
11
- const msw = require('msw');
12
- this.http = msw.http;
13
- this.HttpResponse = msw.HttpResponse;
14
- }
15
- catch (err) {
16
- throw new Error('MSW (Mock Service Worker) is required. Install it with: npm install msw --save-dev');
9
+ if (!msw?.http || !msw?.HttpResponse) {
10
+ throw new Error('LambderMSW requires the msw module: new LambderMSW({ apiPath, msw: await import("msw") }). Install it with: npm install msw --save-dev');
17
11
  }
12
+ this.http = msw.http;
13
+ this.HttpResponse = msw.HttpResponse;
18
14
  }
19
15
  /**
20
16
  * Mock an API endpoint with MSW
@@ -0,0 +1,47 @@
1
+ import type { LambderRenderContext } from "./LambderContext.js";
2
+ import { LambderResponse } from "./LambderResponse.js";
3
+ export type LambderPublicFilesOptions = {
4
+ /**
5
+ * Map the request to a file path under publicPath (app-owned logic, e.g.
6
+ * per-tenant roots: (ctx) => `${brand(ctx.host)}${ctx.path}`). Return
7
+ * null/undefined to skip. Default: (ctx) => ctx.path.
8
+ */
9
+ path?: (ctx: LambderRenderContext) => string | null | undefined;
10
+ /** Cache-Control for served files. Default: "public, max-age=3600". */
11
+ cacheControl?: string | ((ctx: LambderRenderContext, filePath: string) => string);
12
+ /** Filenames matching this get immutableCacheControl. Default: content-hash heuristic. Set false to disable. */
13
+ immutablePattern?: RegExp | false;
14
+ /** Default: "public, max-age=31536000, immutable". */
15
+ immutableCacheControl?: string;
16
+ /** In-memory cache of files for warm invocations. Default: { maxBytes: 32MB, maxFileBytes: 2MB }. Set false to disable. */
17
+ memoryCache?: false | {
18
+ maxBytes?: number;
19
+ maxFileBytes?: number;
20
+ };
21
+ /**
22
+ * Compression per file: "auto" (default: compressible mime + size threshold),
23
+ * true/false, or a function, e.g. (ctx) => /\.(css|js|svg)$/.test(ctx.path).
24
+ */
25
+ compress?: boolean | "auto" | ((ctx: LambderRenderContext) => boolean | "auto");
26
+ };
27
+ /**
28
+ * Terminal public-file handler registered via lambder.servePublicFiles().
29
+ * Runs only when no route matched, so it can never shadow routes registered
30
+ * after it. Serves real files under publicPath (traversal-safe, mime-typed,
31
+ * memory-cached, immutable-cache heuristic for content-hashed assets) and
32
+ * falls through to the route fallback when the file does not exist.
33
+ */
34
+ export declare class LambderPublicFilesHandler {
35
+ private publicPath;
36
+ private options;
37
+ private fileCache;
38
+ private fileCacheBytes;
39
+ constructor(publicPath: string, options: LambderPublicFilesOptions);
40
+ /** Serve the mapped file, or return null to fall through. */
41
+ handle(ctx: LambderRenderContext): Promise<LambderResponse | null>;
42
+ /** Join base+target and require the result to stay under base. */
43
+ private resolveSafe;
44
+ /** Read a file, caching small files in memory for warm invocations. */
45
+ private readFileCached;
46
+ private cacheControlFor;
47
+ }