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 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, and API Gateway HTTP API (payload v2) / Lambda Function URL support.
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
- const page = html`<h1>Hello ${user.name}</h1>`;
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. It can template anything: SPA shells, emails, error pages.
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, jsonScript } from "lambder";
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, // plain values are escaped (XSS-safe)
326
- head: html`<link rel="canonical" href="${canonicalUrl}" />
327
- ${jsonScript("app-data", preloadedState)}`, // html`...`/raw()/jsonScript() inserted verbatim
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
- lambder
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
- Files without template markers can opt into virtual slots (`title` = the `<title>` element, `head` = before `</head>`) with `res.templateFile(path, data, { htmlVirtualSlots: true })`. File cache policies (`cacheControl`, `immutablePattern`, `memoryCache`) are configurable via `LambderPublicFilesOptions`, and both slots take an explicit compression policy: `servePublicFiles({ compress: (ctx) => /\.(css|js|svg)$/.test(ctx.path) })` and `serveIndexHtml(handler, { compress: true })` (default "auto").
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). Values are Brotli-compressed; small values are stored inline in a manifest item, large values are split into versioned binary chunks written before the manifest, so readers only ever see complete versions. Includes an in-memory LRU layer for warm invocations, single-flight deduplication, a DynamoDB lease so only one Lambda fills a missing key, and fail-open semantics (cache infrastructure errors fall back to the loader; loader errors propagate).
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
- Required IAM actions on the table: `dynamodb:GetItem`, `PutItem`, `DeleteItem`, `Query`, `BatchWriteItem`. Server-only (uses AWS SDK + zlib).
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
 
@@ -1,6 +1,5 @@
1
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";
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
- 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);
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) => new Promise((resolve, reject) => {
29
- brotliDecompress(input, { maxOutputLength }, (error, output) => {
30
- if (error)
31
- reject(error);
32
- else
33
- resolve(output);
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) => createHash("sha256").update(value).digest("hex");
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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lambder",
3
- "version": "3.1.1",
3
+ "version": "3.2.1",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",