lambder 3.1.1 → 3.2.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 +57 -65
- package/dist/LambderDdbCache.js +60 -34
- package/dist/LambderI18n.d.ts +103 -0
- package/dist/LambderI18n.js +178 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/package.json +1 -1
package/Readme.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Lambder is a highly opinionated dynamic serverless framework designed to facilitate the management and implementation of routes and APIs within AWS Lambda functions, specifically tailored for TypeScript projects. It provides a streamlined approach to handling HTTP requests, managing sessions, and defining API routes, making serverless application development more intuitive and structured.
|
|
4
4
|
|
|
5
|
-
**New in v3:** Public file serving with `servePublicFiles()` + `serveIndexHtml()`, unified `addAction()` for non-HTTP triggers, automatic gzip + ETag, thrown responses with a real `die`, the comment-based `LambderTemplatingEngine`, type-safe `html`/`xml` tagged templates,
|
|
5
|
+
**New in v3:** Public file serving with `servePublicFiles()` + `serveIndexHtml()`, unified `addAction()` for non-HTTP triggers, automatic gzip + ETag, thrown responses with a real `die`, the comment-based `LambderTemplatingEngine`, type-safe `html`/`xml` tagged templates, API Gateway HTTP API (payload v2) / Lambda Function URL support, the `LambderDdbCache` DynamoDB cache (3.1) and typed translations with `createLambderI18n` (3.2).
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
@@ -14,6 +14,19 @@ Lambder is a highly opinionated dynamic serverless framework designed to facilit
|
|
|
14
14
|
- **Error Handling**: Comprehensive error handling capabilities, including global error handlers and route-specific fallbacks.
|
|
15
15
|
- **Seamless Integration**: Works with API Gateway REST APIs (payload v1), HTTP APIs (payload v2) and Lambda Function URLs; the payload format is detected per event.
|
|
16
16
|
|
|
17
|
+
## Standalone Modules
|
|
18
|
+
|
|
19
|
+
Self-contained tools that ship with the package and work with or without the framework. Each has its own guide:
|
|
20
|
+
|
|
21
|
+
| Module | Guide | Description |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| `html` / `xml` tags + `LambderTemplatingEngine` | [docs/TEMPLATING.md](./docs/TEMPLATING.md) | Type-safe tagged templates and a comment-only HTML template engine (build-pipeline-safe) |
|
|
24
|
+
| `LambderDdbCache` | [docs/DDB_CACHE.md](./docs/DDB_CACHE.md) | DynamoDB-backed compressed JSON cache with lease-based single-fill (server-only) |
|
|
25
|
+
| `createLambderI18n` | [docs/I18N.md](./docs/I18N.md) | Typed translations with enforced/optional languages, component-level extension and auto language detection (isomorphic) |
|
|
26
|
+
| `LambderMSW` | [docs/LAMBDER_MSW.md](./docs/LAMBDER_MSW.md) | Typed MSW mocking of the API contract for frontend development |
|
|
27
|
+
|
|
28
|
+
Also see [docs/TYPE_SAFE_QUICK_START.md](./docs/TYPE_SAFE_QUICK_START.md) and [docs/DYNAMODB_SETUP.md](./docs/DYNAMODB_SETUP.md).
|
|
29
|
+
|
|
17
30
|
## Installation
|
|
18
31
|
|
|
19
32
|
```bash
|
|
@@ -278,23 +291,15 @@ Access the session controller with `lambder.getSessionController(ctx)`:
|
|
|
278
291
|
|
|
279
292
|
### Type-Safe Templating (html / xml)
|
|
280
293
|
|
|
281
|
-
Lambder ships zero-dependency tagged template literals instead of a template engine. Interpolated values are HTML-escaped automatically, and everything is plain TypeScript, so templates are fully type-checked and refactorable.
|
|
294
|
+
Lambder ships zero-dependency tagged template literals instead of a template engine. Interpolated values are HTML-escaped automatically, and everything is plain TypeScript, so templates are fully type-checked and refactorable. **Full guide: [docs/TEMPLATING.md](./docs/TEMPLATING.md).**
|
|
282
295
|
|
|
283
296
|
```typescript
|
|
284
297
|
import { html, xml, raw } from "lambder";
|
|
285
298
|
|
|
286
|
-
// Values are escaped by default (XSS-safe)
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
// Arrays flatten; nested fragments are not double-escaped:
|
|
299
|
+
// Values are escaped by default (XSS-safe); arrays flatten; nested fragments
|
|
300
|
+
// are not double-escaped; null/undefined/false render as empty string:
|
|
290
301
|
const list = html`<ul>${items.map((item) => html`<li>${item.label}</li>`)}</ul>`;
|
|
291
302
|
|
|
292
|
-
// Conditionals: null/undefined/false render as empty string:
|
|
293
|
-
const nav = html`${isLoggedIn && html`<a href="/logout">Log out</a>`}`;
|
|
294
|
-
|
|
295
|
-
// raw() inserts trusted markup verbatim (never pass user input):
|
|
296
|
-
const head = html`${raw('<meta charset="utf-8">')}`;
|
|
297
|
-
|
|
298
303
|
// Works for XML too (xml is an alias of html):
|
|
299
304
|
return res.xml(xml`<?xml version="1.0" encoding="UTF-8"?>
|
|
300
305
|
<urlset>${urls.map((loc) => xml`<url><loc>${loc}</loc></url>`)}</urlset>`);
|
|
@@ -302,77 +307,41 @@ return res.xml(xml`<?xml version="1.0" encoding="UTF-8"?>
|
|
|
302
307
|
|
|
303
308
|
### Templating with LambderTemplatingEngine
|
|
304
309
|
|
|
305
|
-
`LambderTemplatingEngine` is a standalone, comment-only HTML template engine. Every construct is an HTML comment, so templates survive HTML build pipelines (e.g. Vite) untouched, and during frontend development the browser simply renders the default content because the markers are invisible.
|
|
306
|
-
|
|
307
|
-
**Syntax** (everything is an HTML comment):
|
|
310
|
+
`LambderTemplatingEngine` is a standalone, comment-only HTML template engine. Every construct is an HTML comment, so templates survive HTML build pipelines (e.g. Vite) untouched, and during frontend development the browser simply renders the default content because the markers are invisible. **Full guide: [docs/TEMPLATING.md](./docs/TEMPLATING.md).**
|
|
308
311
|
|
|
309
312
|
```html
|
|
310
313
|
<title><!--slot:title-->Default Title<!--/slot:title--></title> <!-- replaceable region -->
|
|
311
314
|
<!--slot:head/--> <!-- insert-only point -->
|
|
312
315
|
<!--if:isRtl--><body dir="rtl"><!--else--><body><!--/if:isRtl--> <!-- conditional -->
|
|
313
|
-
<!--if:!minimal--><nav>...</nav><!--/if:!minimal--> <!-- negated conditional -->
|
|
314
316
|
```
|
|
315
317
|
|
|
316
|
-
**Usage** (standalone, importable directly from `lambder`):
|
|
317
|
-
|
|
318
318
|
```typescript
|
|
319
|
-
import { LambderTemplatingEngine, html
|
|
319
|
+
import { LambderTemplatingEngine, html } from "lambder";
|
|
320
320
|
|
|
321
|
-
// Compile once (throws early on unclosed/mismatched blocks) ...
|
|
322
321
|
const template = await LambderTemplatingEngine.fromFile("./templates/page.html");
|
|
323
|
-
// ... render many times, per request:
|
|
324
322
|
const output = template.render({
|
|
325
|
-
title: userInput,
|
|
326
|
-
head: html`<link rel="canonical" href="${canonicalUrl}"
|
|
327
|
-
|
|
328
|
-
isRtl: lang === "ar", // condition names use truthiness
|
|
323
|
+
title: userInput, // escaped (XSS-safe)
|
|
324
|
+
head: html`<link rel="canonical" href="${canonicalUrl}" />`,
|
|
325
|
+
isRtl: lang === "ar",
|
|
329
326
|
});
|
|
330
|
-
|
|
331
|
-
// Runtime introspection (dynamically typed by design):
|
|
332
|
-
template.slotNames; // e.g. ["title", "head"]
|
|
333
|
-
template.conditionNames; // e.g. ["isRtl", "minimal"]
|
|
334
|
-
template.has("title"); // true
|
|
335
327
|
```
|
|
336
328
|
|
|
337
|
-
Rules:
|
|
338
|
-
- Slot values: strings/numbers escaped; `html`/`raw()`/`jsonScript()` verbatim; arrays flattened; `null`/`undefined`/`false` keep the slot's default content
|
|
339
|
-
- Unknown data keys are ignored, so one data object can serve several templates with different slots
|
|
340
|
-
- Blocks nest freely; there are intentionally no loops or inline expressions: build dynamic lists server-side with `html` and pass them into a slot
|
|
341
|
-
- Attribute-position values (e.g. `<html lang="...">`) are handled with if/else around whole-tag variants
|
|
342
|
-
|
|
343
329
|
### Hosting a frontend build (servePublicFiles + templateFile)
|
|
344
330
|
|
|
345
|
-
Lambder has no SPA-specific machinery; hosting a frontend build is a recipe built from three generic primitives:
|
|
346
|
-
|
|
347
|
-
1. **`servePublicFiles(options?)`**: a terminal slot that serves real files under `publicPath`. It runs only when no route or API matched, so unlike a `"/(.*)"` catch-all route it can never shadow routes registered after it. Traversal-safe, mime-typed, memory-cached for warm invocations, immutable Cache-Control for content-hashed assets (`app-4f8a1b2c.js`), automatic ETag/gzip. When the file does not exist, the request **falls through**.
|
|
348
|
-
2. **`serveIndexHtml(handler?, options?)`**: the next slot in the fallback chain, gated by a built-in filter: only `GET`/`HEAD` (option `methods`) and, by default, only paths that do not look like files (`skipFilePaths: true`, so a missing `/logo.png` is a 404, not a soft-404 HTML shell). Optional `redirectTrailingSlash` (default false) 301s `/about/` to `/about`. Gated-out requests fall through to `setRouteFallbackHandler`. Without a handler it serves `publicPath/index.html` (option `indexFile`) via `res.templateFile` with `no-cache`, so plain hosting is zero-config and templating is opt-in.
|
|
349
|
-
3. **`res.templateFile(path, data?, options?)`**: render any HTML file under `publicPath` through `LambderTemplatingEngine` (compiled once, cached across warm invocations) and return it as an HTML response.
|
|
350
|
-
|
|
351
|
-
```html
|
|
352
|
-
<!-- frontend index.html (markers survive the Vite build; defaults show in vite dev) -->
|
|
353
|
-
<title><!--slot:title-->My App<!--/slot:title--></title>
|
|
354
|
-
<!--slot:head/-->
|
|
355
|
-
```
|
|
331
|
+
Lambder has no SPA-specific machinery; hosting a frontend build is a recipe built from three generic primitives: `servePublicFiles()` (terminal slot serving real files: memory-cached, immutable Cache-Control for hashed assets, ETag/gzip, falls through when missing), `serveIndexHtml()` (next fallback slot, GET/HEAD + non-file-path gated) and `res.templateFile()` (render an HTML file through the templating engine, compiled once and cached). **Full guide with the multi-tenant recipe: [docs/TEMPLATING.md](./docs/TEMPLATING.md).**
|
|
356
332
|
|
|
357
333
|
```typescript
|
|
358
|
-
|
|
359
|
-
// Multi-tenant roots are just app logic in the path mapper:
|
|
360
|
-
.servePublicFiles({ path: (ctx) => `${getBrandFromHost(ctx.host)}${ctx.path}` })
|
|
361
|
-
// Only GET/HEAD page requests reach this handler:
|
|
362
|
-
.serveIndexHtml(async (ctx, res) => {
|
|
363
|
-
return res.templateFile(`${getBrandFromHost(ctx.host)}/index.html`, {
|
|
364
|
-
title: pageTitle(ctx), // escaped automatically
|
|
365
|
-
head: html`<link rel="canonical" href="${canonicalUrl(ctx)}" />
|
|
366
|
-
${jsonScript("app-data", preloadedState(ctx))}`,
|
|
367
|
-
isRtl: activeLang(ctx) === "ar",
|
|
368
|
-
}, { cacheControl: "no-cache" });
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
// Or, zero-config for a single-tenant app without templating:
|
|
334
|
+
// Zero-config single-tenant hosting:
|
|
372
335
|
lambder.servePublicFiles().serveIndexHtml();
|
|
373
|
-
```
|
|
374
336
|
|
|
375
|
-
|
|
337
|
+
// Templated shell:
|
|
338
|
+
lambder.servePublicFiles().serveIndexHtml(async (ctx, res) => {
|
|
339
|
+
return res.templateFile("index.html", {
|
|
340
|
+
title: pageTitle(ctx),
|
|
341
|
+
head: html`<link rel="canonical" href="${canonicalUrl(ctx)}" />`,
|
|
342
|
+
}, { cacheControl: "no-cache" });
|
|
343
|
+
});
|
|
344
|
+
```
|
|
376
345
|
|
|
377
346
|
### Render Context (ctx) Variables
|
|
378
347
|
|
|
@@ -430,7 +399,7 @@ Responses are finalized once at the end of the request: automatic gzip (when the
|
|
|
430
399
|
|
|
431
400
|
### DynamoDB Cache (LambderDdbCache)
|
|
432
401
|
|
|
433
|
-
Standalone, persistent JSON cache backed by a DynamoDB table (`pk`/`sk` keys + `expiresAt` TTL attribute, same shape as the session table).
|
|
402
|
+
Standalone, persistent JSON cache backed by a DynamoDB table (`pk`/`sk` keys + `expiresAt` TTL attribute, same shape as the session table). Brotli-compressed values, in-memory LRU layer, single-flight deduplication, a DynamoDB lease so only one Lambda fills a missing key, and fail-open semantics. Server-only. **Full guide with table setup: [docs/DDB_CACHE.md](./docs/DDB_CACHE.md).**
|
|
434
403
|
|
|
435
404
|
```typescript
|
|
436
405
|
import { LambderDdbCache } from "lambder";
|
|
@@ -448,7 +417,30 @@ const city = await cache.getOrSet(`city:${slug}`, async () => fetchCityFromDb(sl
|
|
|
448
417
|
// Also: cache.get(key), cache.set(key, value, { ttlSeconds }), cache.has(key), cache.delete(key)
|
|
449
418
|
```
|
|
450
419
|
|
|
451
|
-
|
|
420
|
+
### Typed Translations (createLambderI18n)
|
|
421
|
+
|
|
422
|
+
Standalone, framework-free i18n with a compile-time contract: keys and `{token}` params are inferred from the default-language dictionary, components extend the base keys with their own (strictly, or partially with fallback), and the active language resolves automatically (custom detector → browser languages → default). **Full guide: [docs/I18N.md](./docs/I18N.md).**
|
|
423
|
+
|
|
424
|
+
```typescript
|
|
425
|
+
import { createLambderI18n } from "lambder";
|
|
426
|
+
|
|
427
|
+
export const i18n = createLambderI18n({
|
|
428
|
+
languages: { en: { name: "English" }, tr: { name: "Türkçe" }, de: { name: "Deutsch" } },
|
|
429
|
+
defaultLanguage: "en",
|
|
430
|
+
enforced: ["en"], // languages every dictionary must provide
|
|
431
|
+
base: { // strict: all languages, all keys
|
|
432
|
+
en: { greet: "Hello {name}" },
|
|
433
|
+
tr: { greet: "Merhaba {name}" },
|
|
434
|
+
de: { greet: "Hallo {name}" },
|
|
435
|
+
},
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// componentA.ts — only enforced languages required; de falls back to en:
|
|
439
|
+
const cI18n = i18n.extendPartial({ en: { compute: "Compute" }, tr: { compute: "Hesapla" } });
|
|
440
|
+
cI18n.t("compute"); // auto-resolved language
|
|
441
|
+
cI18n.t("greet", { name: "Ada" }); // base keys + params, compile-time enforced
|
|
442
|
+
cI18n.forLanguage("tr")("compute"); // explicit (per-request backend use)
|
|
443
|
+
```
|
|
452
444
|
|
|
453
445
|
## Frontend Usage with LambderCaller
|
|
454
446
|
|
package/dist/LambderDdbCache.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { BatchWriteItemCommand, DeleteItemCommand, DynamoDBClient, GetItemCommand, PutItemCommand, QueryCommand, } from "@aws-sdk/client-dynamodb";
|
|
2
|
-
import {
|
|
3
|
-
import { brotliCompress, brotliDecompress, constants as zlibConstants, } from "zlib";
|
|
2
|
+
import { getCrypto, getZlib } from "./node-polyfills.js";
|
|
4
3
|
import { LRUCache } from "lru-cache";
|
|
5
4
|
const DEFAULT_TTL_SECONDS = 365 * 24 * 60 * 60;
|
|
6
5
|
const DEFAULT_CHUNK_BYTES = 350 * 1024;
|
|
@@ -11,29 +10,56 @@ const META_SORT_KEY = "meta";
|
|
|
11
10
|
const LOCK_SORT_KEY = "lock";
|
|
12
11
|
const BATCH_WRITE_LIMIT = 25;
|
|
13
12
|
const MAX_BATCH_RETRIES = 8;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
+
});
|
|
26
42
|
});
|
|
27
|
-
}
|
|
28
|
-
const decompress = (input, maxOutputLength) =>
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
+
});
|
|
34
53
|
});
|
|
35
|
-
}
|
|
36
|
-
const sha256 = (value) =>
|
|
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
|
+
};
|
|
37
63
|
const positiveInteger = (value, name) => {
|
|
38
64
|
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
39
65
|
throw new Error(`${name} must be a positive safe integer`);
|
|
@@ -105,7 +131,7 @@ export class LambderDdbCache {
|
|
|
105
131
|
}
|
|
106
132
|
if (cached)
|
|
107
133
|
this.memory?.delete(normalizedKey);
|
|
108
|
-
const pk = this.partitionKey(normalizedKey);
|
|
134
|
+
const pk = await this.partitionKey(normalizedKey);
|
|
109
135
|
const manifest = await this.readManifest(pk);
|
|
110
136
|
if (!manifest || manifest.expiresAt <= nowSeconds)
|
|
111
137
|
return undefined;
|
|
@@ -114,7 +140,7 @@ export class LambderDdbCache {
|
|
|
114
140
|
if (compressed.length !== manifest.compressedBytes) {
|
|
115
141
|
throw new Error("compressed byte length does not match manifest");
|
|
116
142
|
}
|
|
117
|
-
if (sha256(compressed) !== manifest.checksum) {
|
|
143
|
+
if (await sha256(compressed) !== manifest.checksum) {
|
|
118
144
|
throw new Error("compressed checksum does not match manifest");
|
|
119
145
|
}
|
|
120
146
|
const output = await decompress(compressed, this.maxValueBytes);
|
|
@@ -140,7 +166,7 @@ export class LambderDdbCache {
|
|
|
140
166
|
return true;
|
|
141
167
|
if (cached)
|
|
142
168
|
this.memory?.delete(normalizedKey);
|
|
143
|
-
const manifest = await this.readManifest(this.partitionKey(normalizedKey));
|
|
169
|
+
const manifest = await this.readManifest(await this.partitionKey(normalizedKey));
|
|
144
170
|
return !!manifest && manifest.expiresAt > nowSeconds;
|
|
145
171
|
}
|
|
146
172
|
async set(key, value, options = {}) {
|
|
@@ -157,8 +183,8 @@ export class LambderDdbCache {
|
|
|
157
183
|
if (compressed.length > this.maxValueBytes) {
|
|
158
184
|
throw new Error(`Compressed cache value exceeds maxValueBytes (${compressed.length} > ${this.maxValueBytes})`);
|
|
159
185
|
}
|
|
160
|
-
const pk = this.partitionKey(normalizedKey);
|
|
161
|
-
const version = `${Date.now().toString(36)}-${randomUUID()}`;
|
|
186
|
+
const pk = await this.partitionKey(normalizedKey);
|
|
187
|
+
const version = `${Date.now().toString(36)}-${await randomUUID()}`;
|
|
162
188
|
const expiresAt = this.nowSeconds() + ttlSeconds;
|
|
163
189
|
const chunks = [];
|
|
164
190
|
const inline = compressed.length <= this.chunkBytes;
|
|
@@ -187,7 +213,7 @@ export class LambderDdbCache {
|
|
|
187
213
|
chunkCount: { N: String(chunks.length) },
|
|
188
214
|
compressedBytes: { N: String(compressed.length) },
|
|
189
215
|
uncompressedBytes: { N: String(input.length) },
|
|
190
|
-
checksum: { S: sha256(compressed) },
|
|
216
|
+
checksum: { S: await sha256(compressed) },
|
|
191
217
|
encoding: { S: "br" },
|
|
192
218
|
createdAt: { N: String(this.nowSeconds()) },
|
|
193
219
|
expiresAt: { N: String(expiresAt) },
|
|
@@ -198,7 +224,7 @@ export class LambderDdbCache {
|
|
|
198
224
|
}
|
|
199
225
|
async delete(key) {
|
|
200
226
|
const normalizedKey = this.normalizeKey(key);
|
|
201
|
-
const pk = this.partitionKey(normalizedKey);
|
|
227
|
+
const pk = await this.partitionKey(normalizedKey);
|
|
202
228
|
this.memory?.delete(normalizedKey);
|
|
203
229
|
const keys = [];
|
|
204
230
|
let cursor;
|
|
@@ -264,8 +290,8 @@ export class LambderDdbCache {
|
|
|
264
290
|
async fill(key, factory, options) {
|
|
265
291
|
const leaseSeconds = positiveInteger(options.leaseSeconds ?? 15, "leaseSeconds");
|
|
266
292
|
const waitForFillMs = positiveInteger(options.waitForFillMs ?? 5_000, "waitForFillMs");
|
|
267
|
-
const pk = this.partitionKey(key);
|
|
268
|
-
const owner = randomUUID();
|
|
293
|
+
const pk = await this.partitionKey(key);
|
|
294
|
+
const owner = await randomUUID();
|
|
269
295
|
if (await this.acquireLease(pk, owner, leaseSeconds)) {
|
|
270
296
|
try {
|
|
271
297
|
const value = await factory();
|
|
@@ -465,8 +491,8 @@ export class LambderDdbCache {
|
|
|
465
491
|
}
|
|
466
492
|
return key;
|
|
467
493
|
}
|
|
468
|
-
partitionKey(key) {
|
|
469
|
-
return `${this.namespace}#${sha256(key)}`;
|
|
494
|
+
async partitionKey(key) {
|
|
495
|
+
return `${this.namespace}#${await sha256(key)}`;
|
|
470
496
|
}
|
|
471
497
|
chunkSortKey(version, index) {
|
|
472
498
|
return `chunk#${version}#${String(index).padStart(6, "0")}`;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LambderI18n — standalone, framework-free, isomorphic typed translation module.
|
|
3
|
+
*
|
|
4
|
+
* Zero dependencies, no Node/DOM requirements (browser detection is feature-gated),
|
|
5
|
+
* safe to import in both lambda backends and frontend bundles.
|
|
6
|
+
*
|
|
7
|
+
* See docs/I18N.md for the full guide.
|
|
8
|
+
*/
|
|
9
|
+
export interface LambderLanguageMeta {
|
|
10
|
+
/** Native language name (shown in language switchers). */
|
|
11
|
+
name: string;
|
|
12
|
+
/** English language name, for accessibility / tooltips. */
|
|
13
|
+
englishName?: string;
|
|
14
|
+
/** BCP-47 locale for Intl APIs (e.g. "zh-CN"). Defaults to the code. */
|
|
15
|
+
intlLocale?: string;
|
|
16
|
+
/** Text direction. Defaults to "ltr". */
|
|
17
|
+
dir?: "ltr" | "rtl";
|
|
18
|
+
/** App-specific extras (e.g. flag emoji). */
|
|
19
|
+
[extra: string]: unknown;
|
|
20
|
+
}
|
|
21
|
+
/** Extracts `{param}` placeholder names from a string literal type. */
|
|
22
|
+
export type LambderI18nExtractParams<S extends string> = S extends `${string}{${infer P}}${infer Rest}` ? P | LambderI18nExtractParams<Rest> : never;
|
|
23
|
+
/**
|
|
24
|
+
* Typed translator: `t(key)` — and when the key's contract value contains
|
|
25
|
+
* `{tokens}`, a params object with exactly those tokens is required.
|
|
26
|
+
*/
|
|
27
|
+
export type LambderI18nTranslator<TContract extends Record<string, string>> = <K extends keyof TContract & string>(...args: LambderI18nExtractParams<TContract[K]> extends never ? [key: K] : [key: K, params: Record<LambderI18nExtractParams<TContract[K]>, string | number>]) => string;
|
|
28
|
+
export interface LambderI18nConfig<TLanguages extends Record<string, LambderLanguageMeta>, TDefault extends keyof TLanguages & string, TEnforced extends readonly (keyof TLanguages & string)[], TContract extends Record<string, string>> {
|
|
29
|
+
/** Master registry of every supported language and its metadata. */
|
|
30
|
+
languages: TLanguages;
|
|
31
|
+
/** Final fallback language. Must be included in `enforced`. */
|
|
32
|
+
defaultLanguage: TDefault;
|
|
33
|
+
/**
|
|
34
|
+
* Languages every dictionary must always provide. `extendPartial` requires
|
|
35
|
+
* only these; all other languages become optional and fall back.
|
|
36
|
+
*/
|
|
37
|
+
enforced: TEnforced;
|
|
38
|
+
/**
|
|
39
|
+
* App-wide base dictionary. Strict: every language in `languages` must
|
|
40
|
+
* provide every key (the `defaultLanguage` block is the typed contract).
|
|
41
|
+
*/
|
|
42
|
+
base: {
|
|
43
|
+
[L in keyof TLanguages]: Record<keyof TContract, string>;
|
|
44
|
+
} & {
|
|
45
|
+
[D in TDefault]: TContract;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Optional language detector, tried before browser detection. Return a
|
|
49
|
+
* supported code to pick it, or null/undefined to continue the chain:
|
|
50
|
+
* setLanguage override → detectLanguage → browser languages → defaultLanguage.
|
|
51
|
+
*/
|
|
52
|
+
detectLanguage?: (helpers: {
|
|
53
|
+
isLanguageCode: (value: string) => value is keyof TLanguages & string;
|
|
54
|
+
languages: TLanguages;
|
|
55
|
+
defaultLanguage: TDefault;
|
|
56
|
+
}) => string | null | undefined;
|
|
57
|
+
}
|
|
58
|
+
export interface LambderI18nInstance<TLanguages extends Record<string, LambderLanguageMeta>, TDefault extends keyof TLanguages & string, TEnforced extends readonly (keyof TLanguages & string)[], TContract extends Record<string, string>> {
|
|
59
|
+
/** Translate using the automatically resolved active language. */
|
|
60
|
+
t: LambderI18nTranslator<TContract>;
|
|
61
|
+
/** Translator bound to an explicit language (per-request backend use). */
|
|
62
|
+
forLanguage(code: keyof TLanguages & string): LambderI18nTranslator<TContract>;
|
|
63
|
+
/**
|
|
64
|
+
* Strict extension: every language must provide every new key.
|
|
65
|
+
* Returns a new instance whose key space = parent keys + new keys.
|
|
66
|
+
*/
|
|
67
|
+
extend<const TExt extends {
|
|
68
|
+
[D in TDefault]: Record<string, string>;
|
|
69
|
+
}>(dict: {
|
|
70
|
+
[L in keyof TLanguages]: Record<keyof TExt[TDefault], string>;
|
|
71
|
+
} & TExt): LambderI18nInstance<TLanguages, TDefault, TEnforced, TContract & TExt[TDefault]>;
|
|
72
|
+
/**
|
|
73
|
+
* Partial extension: only the `enforced` languages are required; all other
|
|
74
|
+
* languages are optional (and may provide a subset of keys) — missing
|
|
75
|
+
* translations fall back to the default language.
|
|
76
|
+
*/
|
|
77
|
+
extendPartial<const TExt extends {
|
|
78
|
+
[D in TDefault]: Record<string, string>;
|
|
79
|
+
}>(dict: {
|
|
80
|
+
[E in TEnforced[number]]: Record<keyof TExt[TDefault], string>;
|
|
81
|
+
} & {
|
|
82
|
+
[L in Exclude<keyof TLanguages & string, TEnforced[number]>]?: Partial<Record<keyof TExt[TDefault], string>>;
|
|
83
|
+
} & TExt): LambderI18nInstance<TLanguages, TDefault, TEnforced, TContract & TExt[TDefault]>;
|
|
84
|
+
/** Merge additional translations at runtime (e.g. fetched from an API). */
|
|
85
|
+
registerDictionary(code: keyof TLanguages & string, dict: Record<string, string>): void;
|
|
86
|
+
/** Override the active language (shared with all extended instances). */
|
|
87
|
+
setLanguage(code: keyof TLanguages & string): void;
|
|
88
|
+
/** Clear the override and re-run detection. */
|
|
89
|
+
resetLanguage(): void;
|
|
90
|
+
/** The currently active language code. */
|
|
91
|
+
readonly currentLanguage: keyof TLanguages & string;
|
|
92
|
+
/** Metadata of the currently active language. */
|
|
93
|
+
readonly currentLanguageMeta: TLanguages[keyof TLanguages];
|
|
94
|
+
/** Subscribe to language changes. Returns an unsubscribe function. */
|
|
95
|
+
onLanguageChange(listener: (code: keyof TLanguages & string) => void): () => void;
|
|
96
|
+
/** Type guard: is this string a supported language code? */
|
|
97
|
+
isLanguageCode(value: string): value is keyof TLanguages & string;
|
|
98
|
+
readonly languages: TLanguages;
|
|
99
|
+
readonly languageList: (keyof TLanguages & string)[];
|
|
100
|
+
readonly defaultLanguage: TDefault;
|
|
101
|
+
readonly enforced: TEnforced;
|
|
102
|
+
}
|
|
103
|
+
export declare const createLambderI18n: <const TLanguages extends Record<string, LambderLanguageMeta>, const TDefault extends keyof TLanguages & string, const TEnforced extends readonly (keyof TLanguages & string)[], const TContract extends Record<string, string>>(config: LambderI18nConfig<TLanguages, TDefault, TEnforced, TContract>) => LambderI18nInstance<TLanguages, TDefault, TEnforced, TContract>;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LambderI18n — standalone, framework-free, isomorphic typed translation module.
|
|
3
|
+
*
|
|
4
|
+
* Zero dependencies, no Node/DOM requirements (browser detection is feature-gated),
|
|
5
|
+
* safe to import in both lambda backends and frontend bundles.
|
|
6
|
+
*
|
|
7
|
+
* See docs/I18N.md for the full guide.
|
|
8
|
+
*/
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Implementation
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
/** Islamery-style browser detection: ordered prefs, full code then primary subtag. */
|
|
13
|
+
const detectBrowserLanguage = (isCode) => {
|
|
14
|
+
if (typeof navigator === "undefined")
|
|
15
|
+
return null;
|
|
16
|
+
const prefs = navigator.languages?.length ? navigator.languages : [navigator.language];
|
|
17
|
+
for (const pref of prefs ?? []) {
|
|
18
|
+
const lower = (pref ?? "").toLowerCase();
|
|
19
|
+
if (isCode(lower))
|
|
20
|
+
return lower;
|
|
21
|
+
const primary = lower.split("-")[0] ?? "";
|
|
22
|
+
if (isCode(primary))
|
|
23
|
+
return primary;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
};
|
|
27
|
+
/** Mutable active-language state, shared between an instance and all its extensions. */
|
|
28
|
+
class LanguageState {
|
|
29
|
+
isCode;
|
|
30
|
+
defaultLanguage;
|
|
31
|
+
customDetect;
|
|
32
|
+
override = null;
|
|
33
|
+
detected = null;
|
|
34
|
+
listeners = new Set();
|
|
35
|
+
constructor(isCode, defaultLanguage, customDetect) {
|
|
36
|
+
this.isCode = isCode;
|
|
37
|
+
this.defaultLanguage = defaultLanguage;
|
|
38
|
+
this.customDetect = customDetect;
|
|
39
|
+
}
|
|
40
|
+
resolve() {
|
|
41
|
+
if (this.override)
|
|
42
|
+
return this.override;
|
|
43
|
+
if (this.detected)
|
|
44
|
+
return this.detected;
|
|
45
|
+
const custom = this.customDetect?.();
|
|
46
|
+
if (custom && this.isCode(custom)) {
|
|
47
|
+
this.detected = custom;
|
|
48
|
+
return custom;
|
|
49
|
+
}
|
|
50
|
+
const browser = detectBrowserLanguage(this.isCode);
|
|
51
|
+
this.detected = browser ?? this.defaultLanguage;
|
|
52
|
+
return this.detected;
|
|
53
|
+
}
|
|
54
|
+
set(code) {
|
|
55
|
+
if (!this.isCode(code))
|
|
56
|
+
throw new Error(`LambderI18n: unsupported language code "${code}".`);
|
|
57
|
+
if (this.override === code)
|
|
58
|
+
return;
|
|
59
|
+
this.override = code;
|
|
60
|
+
this.notify(code);
|
|
61
|
+
}
|
|
62
|
+
reset() {
|
|
63
|
+
this.override = null;
|
|
64
|
+
this.detected = null;
|
|
65
|
+
this.notify(this.resolve());
|
|
66
|
+
}
|
|
67
|
+
subscribe(listener) {
|
|
68
|
+
this.listeners.add(listener);
|
|
69
|
+
return () => { this.listeners.delete(listener); };
|
|
70
|
+
}
|
|
71
|
+
notify(code) {
|
|
72
|
+
for (const listener of this.listeners)
|
|
73
|
+
listener(code);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const interpolate = (text, params) => {
|
|
77
|
+
if (!params)
|
|
78
|
+
return text;
|
|
79
|
+
let out = text;
|
|
80
|
+
for (const [token, value] of Object.entries(params)) {
|
|
81
|
+
out = out.split(`{${token}}`).join(String(value));
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
};
|
|
85
|
+
const layerLookup = (layer, lang, key) => {
|
|
86
|
+
for (let node = layer; node; node = node.parent) {
|
|
87
|
+
const value = node.dicts[lang]?.[key];
|
|
88
|
+
if (value !== undefined)
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
};
|
|
93
|
+
const buildInstance = (core, layer) => {
|
|
94
|
+
const translateIn = (lang, key, params) => {
|
|
95
|
+
const text = layerLookup(layer, lang, key)
|
|
96
|
+
?? layerLookup(layer, core.defaultLanguage, key)
|
|
97
|
+
?? key;
|
|
98
|
+
return interpolate(text, params);
|
|
99
|
+
};
|
|
100
|
+
const t = (key, params) => translateIn(core.state.resolve(), key, params);
|
|
101
|
+
const validateExtension = (dict, requiredLanguages, label) => {
|
|
102
|
+
for (const lang of Object.keys(dict)) {
|
|
103
|
+
if (!core.isCode(lang))
|
|
104
|
+
throw new Error(`LambderI18n: ${label} contains unsupported language "${lang}".`);
|
|
105
|
+
}
|
|
106
|
+
for (const lang of requiredLanguages) {
|
|
107
|
+
if (!dict[lang])
|
|
108
|
+
throw new Error(`LambderI18n: ${label} is missing required language "${lang}".`);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
const instance = {
|
|
112
|
+
t: t,
|
|
113
|
+
forLanguage(code) {
|
|
114
|
+
if (!core.isCode(code))
|
|
115
|
+
throw new Error(`LambderI18n: unsupported language code "${code}".`);
|
|
116
|
+
return ((key, params) => translateIn(code, key, params));
|
|
117
|
+
},
|
|
118
|
+
extend(dict) {
|
|
119
|
+
validateExtension(dict, core.languageList, "extend() dictionary");
|
|
120
|
+
return buildInstance(core, { dicts: dict, parent: layer });
|
|
121
|
+
},
|
|
122
|
+
extendPartial(dict) {
|
|
123
|
+
validateExtension(dict, core.enforced, "extendPartial() dictionary");
|
|
124
|
+
return buildInstance(core, { dicts: dict, parent: layer });
|
|
125
|
+
},
|
|
126
|
+
registerDictionary(code, dict) {
|
|
127
|
+
if (!core.isCode(code))
|
|
128
|
+
throw new Error(`LambderI18n: unsupported language code "${code}".`);
|
|
129
|
+
layer.dicts[code] = { ...layer.dicts[code], ...dict };
|
|
130
|
+
},
|
|
131
|
+
setLanguage(code) { core.state.set(code); },
|
|
132
|
+
resetLanguage() { core.state.reset(); },
|
|
133
|
+
get currentLanguage() { return core.state.resolve(); },
|
|
134
|
+
get currentLanguageMeta() { return core.languages[core.state.resolve()]; },
|
|
135
|
+
onLanguageChange(listener) { return core.state.subscribe(listener); },
|
|
136
|
+
isLanguageCode: core.isCode,
|
|
137
|
+
languages: core.languages,
|
|
138
|
+
languageList: core.languageList,
|
|
139
|
+
defaultLanguage: core.defaultLanguage,
|
|
140
|
+
enforced: core.enforced,
|
|
141
|
+
};
|
|
142
|
+
return instance;
|
|
143
|
+
};
|
|
144
|
+
export const createLambderI18n = (config) => {
|
|
145
|
+
const languageList = Object.keys(config.languages);
|
|
146
|
+
const isCode = (value) => Object.prototype.hasOwnProperty.call(config.languages, value);
|
|
147
|
+
if (!isCode(config.defaultLanguage)) {
|
|
148
|
+
throw new Error(`LambderI18n: defaultLanguage "${config.defaultLanguage}" is not in languages.`);
|
|
149
|
+
}
|
|
150
|
+
for (const lang of config.enforced) {
|
|
151
|
+
if (!isCode(lang))
|
|
152
|
+
throw new Error(`LambderI18n: enforced language "${lang}" is not in languages.`);
|
|
153
|
+
}
|
|
154
|
+
if (!config.enforced.includes(config.defaultLanguage)) {
|
|
155
|
+
throw new Error(`LambderI18n: defaultLanguage "${config.defaultLanguage}" must be listed in enforced.`);
|
|
156
|
+
}
|
|
157
|
+
for (const lang of languageList) {
|
|
158
|
+
if (!config.base[lang]) {
|
|
159
|
+
throw new Error(`LambderI18n: base dictionary is missing language "${lang}".`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const customDetect = config.detectLanguage
|
|
163
|
+
? () => config.detectLanguage({
|
|
164
|
+
isLanguageCode: isCode,
|
|
165
|
+
languages: config.languages,
|
|
166
|
+
defaultLanguage: config.defaultLanguage,
|
|
167
|
+
})
|
|
168
|
+
: null;
|
|
169
|
+
const core = {
|
|
170
|
+
languages: config.languages,
|
|
171
|
+
languageList,
|
|
172
|
+
defaultLanguage: config.defaultLanguage,
|
|
173
|
+
enforced: config.enforced,
|
|
174
|
+
state: new LanguageState(isCode, config.defaultLanguage, customDetect),
|
|
175
|
+
isCode,
|
|
176
|
+
};
|
|
177
|
+
return buildInstance(core, { dicts: { ...config.base }, parent: null });
|
|
178
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -19,6 +19,8 @@ export type { LambderSessionCookieOptions } from "./LambderSessionController.js"
|
|
|
19
19
|
export type { LambderSessionContext } from "./LambderSessionManager.js";
|
|
20
20
|
export { LambderDdbCache } from "./LambderDdbCache.js";
|
|
21
21
|
export type { LambderDdbCacheOptions, LambderDdbCacheSetOptions, LambderDdbCacheGetOrSetOptions, } from "./LambderDdbCache.js";
|
|
22
|
+
export { createLambderI18n } from "./LambderI18n.js";
|
|
23
|
+
export type { LambderLanguageMeta, LambderI18nConfig, LambderI18nInstance, LambderI18nTranslator, LambderI18nExtractParams, } from "./LambderI18n.js";
|
|
22
24
|
export { type ApiContractShape, } from "./LambderApiContract.js";
|
|
23
25
|
export type { LambderRenderContext, LambderSessionRenderContext, LambderHttpEvent } from "./LambderContext.js";
|
|
24
26
|
export { createContext, isV2HttpEvent } from "./LambderContext.js";
|
package/dist/index.js
CHANGED
|
@@ -16,4 +16,6 @@ export { LambderTemplatingEngine } from "./LambderTemplatingEngine.js";
|
|
|
16
16
|
export { LambderPublicFilesHandler } from "./LambderPublicFiles.js";
|
|
17
17
|
// DynamoDB-backed compressed cache (standalone, server-only)
|
|
18
18
|
export { LambderDdbCache } from "./LambderDdbCache.js";
|
|
19
|
+
// Typed translations (standalone, isomorphic)
|
|
20
|
+
export { createLambderI18n } from "./LambderI18n.js";
|
|
19
21
|
export { createContext, isV2HttpEvent } from "./LambderContext.js";
|