lambder 7.2.1 → 7.2.2

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/CHANGELOG.md CHANGED
@@ -9,6 +9,34 @@ sit on its first published patch, and later patches list only what they changed.
9
9
  Releases up to 3.2.6 carry git tags; the ones after it were published without
10
10
  one, so versions are not cross-linked to tag comparisons here.
11
11
 
12
+ ## [7.2.2] - 2026-09-18
13
+
14
+ ### Added
15
+
16
+ - **Languages loaded on demand in `createLambderI18n`.** Any language block
17
+ except the default one, in `base`, `extend` and `extendPartial`, can be a
18
+ loader instead of the dictionary: a function resolving to the dictionary or
19
+ to a module whose default export is one, so `tr: () => import("./tr")` is
20
+ the whole thing and a bundler gives each language its own file. Until now
21
+ every declared language had to be written inline, so every visitor
22
+ downloaded every language. A loader is checked against the same contract as
23
+ an inline block, and a missing key is a compile error that names it. The
24
+ default block stays inline: it is the contract and the fallback every
25
+ lookup ends in, and creation refuses a loader there.
26
+ - **`i18n.loadLanguage(code?)`** runs a language's loaders (the active
27
+ language by default) across every instance sharing the root, and resolves
28
+ once their dictionaries are merged. Until then `t` falls back per key to
29
+ the default language, as it does for any missing translation. Concurrent
30
+ calls share each load, and change listeners fire once per load. A loader
31
+ that answered never runs again; one that rejected rejects the call and runs
32
+ again on the next. Creating an extension loads nothing, so one created
33
+ after its language was loaded awaits its own `loadLanguage()`, which runs
34
+ only what is still missing. `setLanguage` and `resetLanguage` start the
35
+ loaders of the language they switch to, and listeners fire again when they
36
+ land; loading first switches without a flash of the default language.
37
+ Configs without loaders behave exactly as before. The
38
+ `LambderI18nDictionaryLoader` type is exported from both entries.
39
+
12
40
  ## [7.2.1] - 2026-09-15
13
41
 
14
42
  ### Added
package/README.md CHANGED
@@ -146,7 +146,7 @@ guide that matches what you are building. The full index lives in
146
146
  | [Frontend client](./docs/client.md) | `LambderCaller`: typed calls, failure outcomes, timeouts, guard inputs, request compression, transports |
147
147
  | [Frontend hosting](./docs/frontend-hosting.md) | File sources, `servePublicFiles`, `serveIndexHtml`, `res.templateFile` |
148
148
  | [Templating](./docs/templating.md) | `html`/`xml` tagged templates and `LambderTemplatingEngine` |
149
- | [Translations](./docs/i18n.md) | `createLambderI18n`: typed keys, extension, detection, runtime dictionaries |
149
+ | [Translations](./docs/i18n.md) | `createLambderI18n`: typed keys, extension, detection, on-demand languages, runtime dictionaries |
150
150
  | [The mock runtime](./docs/mock.md) | `LambderMockApp`: the typed contract served from mock handlers over the real pipeline, in the browser and in tests |
151
151
  | [DynamoDB tables](./docs/dynamodb-tables.md) | Table shapes, TTL and IAM for sessions, cache, rate limits and idempotency |
152
152
  | [Exports reference](./docs/exports.md) | Every name the three entry points export, grouped by purpose |
@@ -159,7 +159,7 @@ framework:
159
159
  | Module | Guide | Description |
160
160
  | --- | --- | --- |
161
161
  | `html` / `xml` tags + `LambderTemplatingEngine` | [Templating](./docs/templating.md) | Type-safe tagged templates and a comment-only HTML template engine (build-pipeline-safe) |
162
- | `createLambderI18n` | [Translations](./docs/i18n.md) | Typed translations with enforced/optional languages, component-level extension and auto language detection (isomorphic) |
162
+ | `createLambderI18n` | [Translations](./docs/i18n.md) | Typed translations with enforced/optional languages, component-level extension, auto language detection and on-demand language loading (isomorphic) |
163
163
  | `LambderDdbCache` | [DynamoDB cache](./docs/ddb-cache.md) | DynamoDB-backed compressed JSON cache with lease-based single-fill and grouped keys (server-only) |
164
164
  | `LambderDdbRateLimiter` / `LambderMemoryRateLimiter` | [Rate limiter](./docs/ddb-rate-limiter.md) | Fixed-window rate limiter, atomic per window, in DynamoDB (server-only) or in memory |
165
165
  | `LambderDdbIdempotencyStore` / `LambderMemoryIdempotencyStore` | [Idempotency store](./docs/ddb-idempotency.md) | Idempotency records with owner-checked claims, in DynamoDB (compressed replays, server-only) or in memory |
package/dist/client.d.ts CHANGED
@@ -32,5 +32,5 @@ export { resolveCompressionOption } from "./shared/wire/LambderCompressionOption
32
32
  export type { LambderCompressionOption, LambderCompressionSettingsBase } from "./shared/wire/LambderCompressionOption.js";
33
33
  export { html, xml, raw, jsonScript, escapeHtml, renderHtmlValue, LambderSafeHtml, type LambderHtmlValue } from "./shared/LambderHtml.js";
34
34
  export { createLambderI18n } from "./shared/LambderI18n.js";
35
- export type { LambderLanguageMeta, LambderI18nConfig, LambderI18nInstance, LambderI18nTranslator, LambderI18nExtractParams, LambderI18nCodes, LambderI18nKeys, LambderI18nTranslatorFor, } from "./shared/LambderI18n.js";
35
+ export type { LambderLanguageMeta, LambderI18nConfig, LambderI18nInstance, LambderI18nTranslator, LambderI18nExtractParams, LambderI18nDictionaryLoader, LambderI18nCodes, LambderI18nKeys, LambderI18nTranslatorFor, } from "./shared/LambderI18n.js";
36
36
  export type { LambderHttpStatusCode } from "./shared/wire/LambderHttpStatus.js";
package/dist/index.d.ts CHANGED
@@ -103,7 +103,7 @@ export type { LambderRateLimitKeyFn, LambderRateLimitPer, LambderRateLimitBudget
103
103
  export type { LambderApiIdempotencyConfig } from "./api/LambderApiIdempotency.js";
104
104
  export type { LambderGuardsOptionValue, LambderRateLimitOverride, LambderRateLimitOptionValue, LambderApiIdempotencyOption, } from "./shared/wire/LambderApiOptionValues.js";
105
105
  export { createLambderI18n } from "./shared/LambderI18n.js";
106
- export type { LambderLanguageMeta, LambderI18nConfig, LambderI18nInstance, LambderI18nTranslator, LambderI18nExtractParams, LambderI18nCodes, LambderI18nKeys, LambderI18nTranslatorFor, } from "./shared/LambderI18n.js";
106
+ export type { LambderLanguageMeta, LambderI18nConfig, LambderI18nInstance, LambderI18nTranslator, LambderI18nExtractParams, LambderI18nDictionaryLoader, LambderI18nCodes, LambderI18nKeys, LambderI18nTranslatorFor, } from "./shared/LambderI18n.js";
107
107
  export type { LambderApiContractShape, LambderApiMode, LambderApiEnvelopeBody, LambderApiResponseConfig, LambderApiNullAnswerConfig, LambderContractEntry, LambderMergeContract, LambderGuardNamesIn, LambderContractMode, LambderContractKeysWithMode, LambderContractGuardsOf, LambderContractGuardNames, LambderContractGuardInputsOf, LambderContractGuardInput, LambderContractGuardInputNames, LambderContractRateLimitOf, LambderContractIdempotencyOf, } from "./shared/wire/LambderApiContract.js";
108
108
  export type { LambderRenderContext, LambderSessionRenderContext, LambderHttpEvent, LambderHttpEventFormat } from "./core/LambderContext.js";
109
109
  export type { LambderApiAnswerOutcome, LambderApiSuccessOutcome, LambderApiCallFailure, LambderApiValidationFailure, LambderApiEnvelopeFailure, LambderApiHttpAnswer, } from "./shared/wire/LambderApiOutcome.js";
@@ -25,6 +25,21 @@ export type LambderI18nExtractParams<S extends string> = S extends `${string}{${
25
25
  * `{tokens}`, a params object with exactly those tokens is required.
26
26
  */
27
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
+ /**
29
+ * A language block fetched on demand instead of bundled: a function that
30
+ * resolves to the dictionary, or to a module whose default export is the
31
+ * dictionary, so `() => import("./tr")` is a loader. It runs when
32
+ * `loadLanguage` asks for its language, never before.
33
+ */
34
+ export type LambderI18nDictionaryLoader<TDict> = () => Promise<TDict | {
35
+ default: TDict;
36
+ }>;
37
+ /**
38
+ * What a non-default language block is checked against: a loader when one was
39
+ * given, the dictionary otherwise. Checking against the matching side alone,
40
+ * rather than the union, is what lets a compile error name the missing key.
41
+ */
42
+ type LanguageBlockFor<TBlocks, L, TDict> = L extends keyof TBlocks ? TBlocks[L] extends (...args: never[]) => unknown ? LambderI18nDictionaryLoader<TDict> : TDict : TDict;
28
43
  export interface LambderI18nConfig<TLanguages extends Record<string, LambderLanguageMeta>, TDefault extends keyof TLanguages & string, TEnforced extends readonly (keyof TLanguages & string)[], TBase extends Record<TDefault, Record<string, string>>> {
29
44
  /** Master registry of every supported language and its metadata. */
30
45
  languages: TLanguages;
@@ -38,9 +53,12 @@ export interface LambderI18nConfig<TLanguages extends Record<string, LambderLang
38
53
  /**
39
54
  * App-wide base dictionary. Strict: every language in `languages` must
40
55
  * provide every key (the `defaultLanguage` block is the typed contract).
56
+ * Any language but the default may be a loader instead
57
+ * (`tr: () => import("./tr")`), fetched by `loadLanguage`. The default
58
+ * block stays inline, because every lookup falls back to it.
41
59
  */
42
60
  base: TBase & {
43
- [L in keyof TLanguages]: Record<keyof TBase[TDefault], string>;
61
+ [L in keyof TLanguages]: L extends TDefault ? Record<keyof TBase[TDefault], string> : LanguageBlockFor<TBase, L, Record<keyof TBase[TDefault], string>>;
44
62
  };
45
63
  /**
46
64
  * Optional language detector, tried before browser detection. Return a
@@ -61,12 +79,13 @@ export interface LambderI18nInstance<TLanguages extends Record<string, LambderLa
61
79
  /**
62
80
  * Strict extension: every language must provide every new key. Keys must
63
81
  * be new: redeclaring a parent key is a compile-time and runtime error.
82
+ * Any language but the default may be a loader, as in `base`.
64
83
  * Returns a new instance whose key space = parent keys + new keys.
65
84
  */
66
85
  extend<const TExt extends {
67
86
  [D in TDefault]: Record<string, string>;
68
87
  }>(dict: {
69
- [L in keyof TLanguages]: Record<keyof TExt[TDefault], string>;
88
+ [L in keyof TLanguages]: L extends TDefault ? Record<keyof TExt[TDefault], string> : LanguageBlockFor<TExt, L, Record<keyof TExt[TDefault], string>>;
70
89
  } & {
71
90
  [D in TDefault]: Partial<Record<keyof TContract, never>>;
72
91
  } & TExt): LambderI18nInstance<TLanguages, TDefault, TEnforced, TContract & TExt[TDefault]>;
@@ -75,19 +94,39 @@ export interface LambderI18nInstance<TLanguages extends Record<string, LambderLa
75
94
  * languages are optional (and may provide a subset of keys), and missing
76
95
  * translations fall back to the default language. Keys must be new:
77
96
  * redeclaring a parent key is a compile-time and runtime error.
97
+ * Any language but the default may be a loader, as in `base`.
78
98
  */
79
99
  extendPartial<const TExt extends {
80
100
  [D in TDefault]: Record<string, string>;
81
101
  }>(dict: {
82
- [E in TEnforced[number]]: Record<keyof TExt[TDefault], string>;
102
+ [E in TEnforced[number]]: E extends TDefault ? Record<keyof TExt[TDefault], string> : LanguageBlockFor<TExt, E, Record<keyof TExt[TDefault], string>>;
83
103
  } & {
84
- [L in Exclude<keyof TLanguages & string, TEnforced[number]>]?: Partial<Record<keyof TExt[TDefault], string>>;
104
+ [L in Exclude<keyof TLanguages & string, TEnforced[number]>]?: LanguageBlockFor<TExt, L, Partial<Record<keyof TExt[TDefault], string>>>;
85
105
  } & {
86
106
  [D in TDefault]: Partial<Record<keyof TContract, never>>;
87
107
  } & TExt): LambderI18nInstance<TLanguages, TDefault, TEnforced, TContract & TExt[TDefault]>;
108
+ /**
109
+ * Run the loaders a language has in this instance and in every instance
110
+ * sharing its root, and resolve once their dictionaries are merged.
111
+ * Defaults to the active language; resolves at once when nothing is left
112
+ * to load. Until then `t` falls back per key to the default language, so
113
+ * await it before the first render, and before `setLanguage` to switch
114
+ * without a flash of the default language. Change listeners fire once
115
+ * per load, however many calls share it. A loader that answered never
116
+ * runs again; one that rejected rejects this call and runs again on the
117
+ * next. Creating an extension loads nothing: one created after its
118
+ * language was loaded awaits its own `loadLanguage()`, which runs only
119
+ * what is still missing.
120
+ */
121
+ loadLanguage(code?: keyof TLanguages & string): Promise<void>;
88
122
  /** Merge additional translations at runtime (e.g. fetched from an API). Notifies change listeners. */
89
123
  registerDictionary(code: keyof TLanguages & string, dict: Record<string, string>): void;
90
- /** Override the active language (shared with all extended instances). */
124
+ /**
125
+ * Override the active language (shared with all extended instances), and
126
+ * start its loaders; change listeners fire again when they land. Await
127
+ * `loadLanguage(code)` first to switch without a flash of the default
128
+ * language.
129
+ */
91
130
  setLanguage(code: keyof TLanguages & string): void;
92
131
  /** Clear the override and re-run detection. */
93
132
  resetLanguage(): void;
@@ -136,3 +175,4 @@ export type LambderI18nTranslatorFor<T extends {
136
175
  t: unknown;
137
176
  }> = T["t"];
138
177
  export declare const createLambderI18n: <const TLanguages extends Record<string, LambderLanguageMeta>, const TDefault extends keyof TLanguages & string, const TEnforced extends readonly (keyof TLanguages & string)[], const TBase extends Record<TDefault, Record<string, string>>>(config: LambderI18nConfig<TLanguages, TDefault, TEnforced, TBase>) => LambderI18nInstance<TLanguages, TDefault, TEnforced, TBase[TDefault]>;
178
+ export {};
@@ -108,6 +108,93 @@ const layerLookup = (layer, lang, key) => {
108
108
  }
109
109
  return undefined;
110
110
  };
111
+ const assertNoRedeclaredKeys = (parent, defaultLanguage, block, label) => {
112
+ for (const key of Object.keys(block)) {
113
+ if (layerLookup(parent, defaultLanguage, key) !== undefined) {
114
+ throw new Error(`LambderI18n: ${label} redeclares existing key "${key}".`);
115
+ }
116
+ }
117
+ };
118
+ /** The default block is what every lookup falls back to, so it cannot wait for a loader. */
119
+ const assertInlineDefaultBlock = (dict, defaultLanguage, label) => {
120
+ if (typeof dict[defaultLanguage] === "function") {
121
+ throw new Error(`LambderI18n: ${label} must give the default language "${defaultLanguage}" inline, not as a loader.`);
122
+ }
123
+ };
124
+ const createLayer = (core, sources, parent) => {
125
+ const layer = { dicts: {}, loaders: new Map(), inFlight: new Map(), parent };
126
+ for (const [lang, source] of Object.entries(sources)) {
127
+ if (typeof source === "function")
128
+ layer.loaders.set(lang, source);
129
+ else if (source)
130
+ layer.dicts[lang] = source;
131
+ }
132
+ if (layer.loaders.size > 0)
133
+ core.lazyLayers.add(layer);
134
+ return layer;
135
+ };
136
+ const isObjectValue = (value) => typeof value === "object" && value !== null;
137
+ /**
138
+ * A loader resolves to the dictionary itself or to a module whose default
139
+ * export is the dictionary. The two cannot be confused: a dictionary's values
140
+ * are strings, so a `default` holding an object can only be a module's.
141
+ */
142
+ const dictionaryFromLoaded = (loaded, lang) => {
143
+ const dict = isObjectValue(loaded) && isObjectValue(loaded.default) ? loaded.default : loaded;
144
+ if (!isObjectValue(dict)) {
145
+ throw new Error(`LambderI18n: the "${lang}" loader resolved to neither a dictionary nor a module whose default export is one.`);
146
+ }
147
+ return dict;
148
+ };
149
+ /** Run a layer's loader for a language and merge what it brings, registered as the layer's load under way. */
150
+ const startLayerLoad = (core, layer, lang, loader) => {
151
+ const load = Promise.resolve()
152
+ .then(loader)
153
+ .then((loaded) => {
154
+ // A loader that answered is spent even when its answer is refused:
155
+ // running it again would fetch the same file and fail the same
156
+ // way. Only a loader that rejected stays, to be retried.
157
+ layer.loaders.delete(lang);
158
+ if (layer.loaders.size === 0)
159
+ core.lazyLayers.delete(layer);
160
+ const dict = dictionaryFromLoaded(loaded, lang);
161
+ if (layer.parent)
162
+ assertNoRedeclaredKeys(layer.parent, core.defaultLanguage, dict, `the "${lang}" loader`);
163
+ // Translations registered while the loader ran override what it brought.
164
+ layer.dicts[lang] = { ...dict, ...layer.dicts[lang] };
165
+ })
166
+ .finally(() => { layer.inFlight.delete(lang); });
167
+ layer.inFlight.set(lang, load);
168
+ return load;
169
+ };
170
+ /**
171
+ * loadLanguage for a whole root: every layer still holding a loader for the
172
+ * language. Concurrent calls share each layer's load, and only the call that
173
+ * started loads announces them, so one load is one change event.
174
+ */
175
+ const loadLanguageAcrossRoot = async (core, lang) => {
176
+ const started = [];
177
+ const joined = [];
178
+ for (const layer of core.lazyLayers) {
179
+ const loader = layer.loaders.get(lang);
180
+ if (!loader)
181
+ continue;
182
+ const running = layer.inFlight.get(lang);
183
+ if (running)
184
+ joined.push(running);
185
+ else
186
+ started.push(startLayerLoad(core, layer, lang, loader));
187
+ }
188
+ if (started.length === 0 && joined.length === 0)
189
+ return;
190
+ const [startedResults, joinedResults] = await Promise.all([Promise.allSettled(started), Promise.allSettled(joined)]);
191
+ if (startedResults.some((result) => result.status === "fulfilled"))
192
+ core.state.emitChange();
193
+ const failure = [...startedResults, ...joinedResults]
194
+ .find((result) => result.status === "rejected");
195
+ if (failure)
196
+ throw failure.reason;
197
+ };
111
198
  const buildInstance = (core, layer) => {
112
199
  const translateIn = (lang, key, params) => {
113
200
  const text = layerLookup(layer, lang, key)
@@ -127,14 +214,20 @@ const buildInstance = (core, layer) => {
127
214
  if (!dict[lang])
128
215
  throw new Error(`LambderI18n: ${label} is missing required language "${lang}".`);
129
216
  }
217
+ assertInlineDefaultBlock(dict, core.defaultLanguage, label);
218
+ // A loader's keys are checked the same way once it has run.
130
219
  for (const block of Object.values(dict)) {
131
- for (const key of Object.keys(block ?? {})) {
132
- if (layerLookup(layer, core.defaultLanguage, key) !== undefined) {
133
- throw new Error(`LambderI18n: ${label} redeclares existing key "${key}".`);
134
- }
135
- }
220
+ if (isObjectValue(block))
221
+ assertNoRedeclaredKeys(layer, core.defaultLanguage, block, label);
136
222
  }
137
223
  };
224
+ // setLanguage and resetLanguage cannot hand a failure back, so it is logged
225
+ // and the language keeps falling back to the default one.
226
+ const loadSwitchedLanguage = (lang) => {
227
+ loadLanguageAcrossRoot(core, lang).catch((err) => {
228
+ console.error(`LambderI18n: loading "${lang}" after switching to it failed; it falls back to the default language.`, err);
229
+ });
230
+ };
138
231
  const instance = {
139
232
  t,
140
233
  forLanguage(code) {
@@ -149,11 +242,17 @@ const buildInstance = (core, layer) => {
149
242
  },
150
243
  extend(dict) {
151
244
  validateExtension(dict, core.languageList, "extend() dictionary");
152
- return buildInstance(core, { dicts: { ...dict }, parent: layer });
245
+ return buildInstance(core, createLayer(core, dict, layer));
153
246
  },
154
247
  extendPartial(dict) {
155
248
  validateExtension(dict, core.enforced, "extendPartial() dictionary");
156
- return buildInstance(core, { dicts: { ...dict }, parent: layer });
249
+ return buildInstance(core, createLayer(core, dict, layer));
250
+ },
251
+ loadLanguage(code) {
252
+ const lang = code ?? core.state.resolve();
253
+ if (!core.isCode(lang))
254
+ return Promise.reject(new Error(`LambderI18n: unsupported language code "${lang}".`));
255
+ return loadLanguageAcrossRoot(core, lang);
157
256
  },
158
257
  registerDictionary(code, dict) {
159
258
  if (!core.isCode(code))
@@ -161,8 +260,14 @@ const buildInstance = (core, layer) => {
161
260
  layer.dicts[code] = { ...layer.dicts[code], ...dict };
162
261
  core.state.emitChange();
163
262
  },
164
- setLanguage(code) { core.state.set(code); },
165
- resetLanguage() { core.state.reset(); },
263
+ setLanguage(code) {
264
+ core.state.set(code);
265
+ loadSwitchedLanguage(code);
266
+ },
267
+ resetLanguage() {
268
+ core.state.reset();
269
+ loadSwitchedLanguage(core.state.resolve());
270
+ },
166
271
  get currentLanguage() { return core.state.resolve(); },
167
272
  get currentLanguageMeta() { return core.metaByCode.get(core.state.resolve()); },
168
273
  get currentDir() {
@@ -213,6 +318,7 @@ export const createLambderI18n = (config) => {
213
318
  throw new Error(`LambderI18n: base dictionary contains unsupported language "${lang}".`);
214
319
  }
215
320
  }
321
+ assertInlineDefaultBlock(config.base, config.defaultLanguage, "base dictionary");
216
322
  const customDetect = config.detectLanguage
217
323
  ? () => config.detectLanguage({
218
324
  isLanguageCode: isCode,
@@ -230,6 +336,7 @@ export const createLambderI18n = (config) => {
230
336
  enforced: config.enforced,
231
337
  state: new LanguageState(isCode, config.defaultLanguage, customDetect),
232
338
  isCode,
339
+ lazyLayers: new Set(),
233
340
  };
234
- return buildInstance(core, { dicts: { ...config.base }, parent: null });
341
+ return buildInstance(core, createLayer(core, config.base, null));
235
342
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lambder",
3
- "version": "7.2.1",
3
+ "version": "7.2.2",
4
4
  "sideEffects": false,
5
5
  "description": "Opinionated serverless web framework for TypeScript on AWS Lambda: type-safe APIs from Zod schemas, DynamoDB sessions, and declarative rate limits, authorization guards and idempotency.",
6
6
  "keywords": [