lambder 7.2.1 → 7.2.3

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,50 @@ 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.3] - 2026-09-19
13
+
14
+ ### Added
15
+
16
+ - **`extensibleEnum(schema)`**, exported from both entries: marks an enum
17
+ whose readers tolerate values they were not built with, and the signature
18
+ digest leaves its values out wherever it is output. A list that grows with
19
+ the product (roles, permissions, statuses) and rides in a widely returned
20
+ payload changed the signature of every endpoint returning it, so one new
21
+ permission reloaded every open tab. With the mark, the list growing or
22
+ shrinking reloads only the clients of endpoints that take it as input,
23
+ where its values still count because a removed value is a request the
24
+ server now refuses. The schema's type and validation are unchanged; the
25
+ mark is zod metadata, read from zod's shared registry. An unmarked enum
26
+ digests exactly as before, so upgrading changes no signature.
27
+
28
+ ## [7.2.2] - 2026-09-18
29
+
30
+ ### Added
31
+
32
+ - **Languages loaded on demand in `createLambderI18n`.** Any language block
33
+ except the default one, in `base`, `extend` and `extendPartial`, can be a
34
+ loader instead of the dictionary: a function resolving to the dictionary or
35
+ to a module whose default export is one, so `tr: () => import("./tr")` is
36
+ the whole thing and a bundler gives each language its own file. Until now
37
+ every declared language had to be written inline, so every visitor
38
+ downloaded every language. A loader is checked against the same contract as
39
+ an inline block, and a missing key is a compile error that names it. The
40
+ default block stays inline: it is the contract and the fallback every
41
+ lookup ends in, and creation refuses a loader there.
42
+ - **`i18n.loadLanguage(code?)`** runs a language's loaders (the active
43
+ language by default) across every instance sharing the root, and resolves
44
+ once their dictionaries are merged. Until then `t` falls back per key to
45
+ the default language, as it does for any missing translation. Concurrent
46
+ calls share each load, and change listeners fire once per load. A loader
47
+ that answered never runs again; one that rejected rejects the call and runs
48
+ again on the next. Creating an extension loads nothing, so one created
49
+ after its language was loaded awaits its own `loadLanguage()`, which runs
50
+ only what is still missing. `setLanguage` and `resetLanguage` start the
51
+ loaders of the language they switch to, and listeners fire again when they
52
+ land; loading first switches without a flash of the default language.
53
+ Configs without loaders behave exactly as before. The
54
+ `LambderI18nDictionaryLoader` type is exported from both entries.
55
+
12
56
  ## [7.2.1] - 2026-09-15
13
57
 
14
58
  ### 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 |
@@ -24,9 +24,11 @@ export type LambderApiSignatureEntry = {
24
24
  *
25
25
  * The description is hashed as built, descriptions and titles included: a
26
26
  * schema is what the server says it is, and a client built against a
27
- * different one reloads once. What must hold for the digest to mean anything
28
- * is that a schema is built from static values: one that reads the clock, a
29
- * random source or the environment at construction digests differently in
30
- * the generator's process and on the server.
27
+ * different one reloads once, with one exception the schema declares itself:
28
+ * the values of an extensibleEnum() in an output (see keepShapeOnly). What
29
+ * must hold for the digest to mean anything is that a schema is built from
30
+ * static values: one that reads the clock, a random source or the environment
31
+ * at construction digests differently in the generator's process and on the
32
+ * server.
31
33
  */
32
34
  export declare const apiSignatureOf: (definition: LambderApiDefinition, guards: Record<string, LambderApiGuard<any, any, any>> | undefined) => Promise<string>;
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { toGuardEntries } from "./LambderApiGuards.js";
3
- import { API_SIGNATURE_HEX_LENGTH } from "../shared/wire/LambderApiSignature.js";
3
+ import { API_SIGNATURE_HEX_LENGTH, EXTENSIBLE_ENUM_META_KEY } from "../shared/wire/LambderApiSignature.js";
4
4
  import { sha256HexOf } from "../shared/util/LambderTextDigest.js";
5
5
  /*
6
6
  * The digest of an endpoint's client-facing shape, computed once, by the
@@ -30,7 +30,7 @@ const sortKeys = (value) => {
30
30
  return sorted;
31
31
  };
32
32
  /**
33
- * Two edits to every node zod emits, before it is hashed.
33
+ * Three edits to every node zod emits, before it is hashed.
34
34
  *
35
35
  * The `default` keyword goes. Its value is server behaviour, not shape: a
36
36
  * client never sends it, and its compiled types do not carry it. And for a
@@ -44,11 +44,24 @@ const sortKeys = (value) => {
44
44
  *
45
45
  * `required` is sorted. It is a set, and the order fields are declared in is
46
46
  * not shape either; left as emitted, reordering two fields forced a reload.
47
+ *
48
+ * An enum marked with extensibleEnum() loses its values in an output. Its
49
+ * clients tolerate a value they do not know, so a response carrying one they
50
+ * were not built with, or no longer carrying one they were, changes nothing
51
+ * they can see; the node still says it holds a string. In an input the values
52
+ * stay, since a value dropped from the list is a request an older client may
53
+ * still send and the server now refuses. The mark itself goes in both, so
54
+ * marking an enum changes no input's digest.
47
55
  */
48
- const keepShapeOnly = (node) => {
56
+ const keepShapeOnly = (node, io) => {
49
57
  delete node.default;
50
58
  if (Array.isArray(node.required))
51
59
  node.required.sort();
60
+ if (node[EXTENSIBLE_ENUM_META_KEY] === true) {
61
+ delete node[EXTENSIBLE_ENUM_META_KEY];
62
+ if (io === "output")
63
+ delete node.enum;
64
+ }
52
65
  };
53
66
  /**
54
67
  * A schema as JSON Schema, as zod emits it minus what keepShapeOnly removes.
@@ -56,7 +69,7 @@ const keepShapeOnly = (node) => {
56
69
  * becomes `{}` rather than throwing, because a digest has to exist for every
57
70
  * endpoint; what the digest cannot see is documented with it.
58
71
  */
59
- const jsonSchemaOf = (schema, io) => schema ? z.toJSONSchema(schema, { io, unrepresentable: "any", override: ({ jsonSchema }) => keepShapeOnly(jsonSchema) }) : null;
72
+ const jsonSchemaOf = (schema, io) => schema ? z.toJSONSchema(schema, { io, unrepresentable: "any", override: ({ jsonSchema }) => keepShapeOnly(jsonSchema, io) }) : null;
60
73
  const ownGuard = (guards, name) => guards !== undefined && Object.prototype.hasOwnProperty.call(guards, name) ? guards[name] : undefined;
61
74
  /**
62
75
  * The digest of an endpoint's client-facing shape: its name and mode, its
@@ -69,10 +82,12 @@ const ownGuard = (guards, name) => guards !== undefined && Object.prototype.hasO
69
82
  *
70
83
  * The description is hashed as built, descriptions and titles included: a
71
84
  * schema is what the server says it is, and a client built against a
72
- * different one reloads once. What must hold for the digest to mean anything
73
- * is that a schema is built from static values: one that reads the clock, a
74
- * random source or the environment at construction digests differently in
75
- * the generator's process and on the server.
85
+ * different one reloads once, with one exception the schema declares itself:
86
+ * the values of an extensibleEnum() in an output (see keepShapeOnly). What
87
+ * must hold for the digest to mean anything is that a schema is built from
88
+ * static values: one that reads the clock, a random source or the environment
89
+ * at construction digests differently in the generator's process and on the
90
+ * server.
76
91
  */
77
92
  export const apiSignatureOf = async (definition, guards) => {
78
93
  const guardShapes = toGuardEntries(definition.guards).map(({ name }) => {
package/dist/client.d.ts CHANGED
@@ -15,7 +15,7 @@ export type { LambderApiTransport, LambderApiTransportRequest, LambderTransportF
15
15
  export { LambderCookieJar, parseSetCookie } from "./shared/transport/LambderCookieJar.js";
16
16
  export type { LambderStoredCookie } from "./shared/transport/LambderCookieJar.js";
17
17
  export { resolveApiOutcome } from "./shared/wire/LambderApiOutcome.js";
18
- export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH } from "./shared/wire/LambderApiSignature.js";
18
+ export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH, extensibleEnum } from "./shared/wire/LambderApiSignature.js";
19
19
  export type { LambderApiSignatureMap } from "./shared/wire/LambderApiSignature.js";
20
20
  export { RELOAD_LOOP_WINDOW_MS } from "./client/LambderReloadLoopBreaker.js";
21
21
  export { compareDottedVersions, isDottedVersion } from "./shared/wire/LambderVersionOrder.js";
@@ -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/client.js CHANGED
@@ -14,8 +14,9 @@ export { buildTransportEnvelope, LambderTransportFailure, isLambderTransportFail
14
14
  export { lambderCookieJarTransport } from "./shared/transport/lambderCookieJarTransport.js";
15
15
  export { LambderCookieJar, parseSetCookie } from "./shared/transport/LambderCookieJar.js";
16
16
  export { resolveApiOutcome } from "./shared/wire/LambderApiOutcome.js";
17
- // The per-endpoint signature map a build ships with, how a caller reads it, and the reload-loop window.
18
- export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH } from "./shared/wire/LambderApiSignature.js";
17
+ // The per-endpoint signature map a build ships with, how a caller reads it, the
18
+ // mark a shared schema sets on an enum its readers let grow, and the reload-loop window.
19
+ export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH, extensibleEnum } from "./shared/wire/LambderApiSignature.js";
19
20
  export { RELOAD_LOOP_WINDOW_MS } from "./client/LambderReloadLoopBreaker.js";
20
21
  export { compareDottedVersions, isDottedVersion } from "./shared/wire/LambderVersionOrder.js";
21
22
  // Typed API refusals (isomorphic: shared code may throw them from anywhere;
package/dist/index.d.ts CHANGED
@@ -27,7 +27,7 @@ export type { LambderApiCallContext, LambderApiCallTrace } from "./api/LambderAp
27
27
  export type { LambderApiDefinition } from "./api/LambderApiDefinition.js";
28
28
  export { apiSignatureOf } from "./api/LambderApiSignature.js";
29
29
  export type { LambderApiSignatureEntry } from "./api/LambderApiSignature.js";
30
- export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH } from "./shared/wire/LambderApiSignature.js";
30
+ export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH, extensibleEnum } from "./shared/wire/LambderApiSignature.js";
31
31
  export type { LambderApiSignatureMap } from "./shared/wire/LambderApiSignature.js";
32
32
  export { RELOAD_LOOP_WINDOW_MS } from "./client/LambderReloadLoopBreaker.js";
33
33
  export { compareDottedVersions, isDottedVersion } from "./shared/wire/LambderVersionOrder.js";
@@ -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";
package/dist/index.js CHANGED
@@ -18,7 +18,7 @@ export { LambderAnswerHeaders, getAnswerHeader, setAnswerHeader, addAnswerHeader
18
18
  export { createApiCallContext } from "./api/LambderApiCallContext.js";
19
19
  // Per-endpoint signatures: what a client build ships with, digested from the server's own registrations.
20
20
  export { apiSignatureOf } from "./api/LambderApiSignature.js";
21
- export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH } from "./shared/wire/LambderApiSignature.js";
21
+ export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH, extensibleEnum } from "./shared/wire/LambderApiSignature.js";
22
22
  export { RELOAD_LOOP_WINDOW_MS } from "./client/LambderReloadLoopBreaker.js";
23
23
  export { compareDottedVersions, isDottedVersion } from "./shared/wire/LambderVersionOrder.js";
24
24
  export { buildApiEnvelope, envelopeAnswer, refusalAnswer, validationAnswer, apiNotFoundAnswer, sessionExpiredAnswer, versionExpiredAnswer, invalidPayloadAnswer, crashAnswer, API_ANSWER_CONTENT_TYPE, } from "./api/LambderApiEnvelope.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
  };
@@ -1,3 +1,4 @@
1
+ import type { z } from "zod";
1
2
  /**
2
3
  * The per-endpoint signatures a client carries, generated from the server's
3
4
  * own registrations (Lambder.apiSignatures()) and shipped with the client
@@ -44,3 +45,39 @@ export declare const lookupApiSignature: (signatures: LambderApiSignatureMap, ap
44
45
  * run instead of saying so.
45
46
  */
46
47
  export declare const readApiSignature: (signatures: LambderApiSignatureMap, apiName: string) => Promise<string>;
48
+ /**
49
+ * The metadata key extensibleEnum() sets and the digest reads. Namespaced,
50
+ * because zod writes metadata into the JSON Schema it emits, so the key shows
51
+ * up in any schema an app converts for itself, where OpenAPI's own
52
+ * `x-extensible-enum` means something else (the values, in place of `enum`).
53
+ */
54
+ export declare const EXTENSIBLE_ENUM_META_KEY = "x-lambder-extensible-enum";
55
+ /**
56
+ * Marks an enum whose clients tolerate a value they were not built with, so
57
+ * its list of values stays out of the signature of every endpoint that
58
+ * returns it. Adding a role, a status or a locale to a list that rides in a
59
+ * widely returned payload (a session, a profile) then reloads only the
60
+ * clients that send the list back, not every client that reads it.
61
+ *
62
+ * Where the enum is input its values still count: a value dropped from the
63
+ * list is a request an older client may still send and the server now
64
+ * refuses, so that endpoint's clients must reload. Everything else about the
65
+ * schema is untouched: its type, its validation on both sides, and what it
66
+ * is everywhere outside the digest.
67
+ *
68
+ * The mark is a promise the schema makes for its readers, and nothing checks
69
+ * it. A client that switches over every value with no fallback, or indexes a
70
+ * map by one, renders a value it does not know as nothing, or throws. Mark
71
+ * only a list every reader handles that way on purpose.
72
+ *
73
+ * It is zod metadata (`.meta()`), which zod keeps in one registry on
74
+ * globalThis, so an enum marked in a shared package is read by the digest
75
+ * even when the server resolves another copy of zod. A schema derived from a
76
+ * marked enum by rebuilding it (`z.enum(marked.options)`, `.exclude()`)
77
+ * carries no mark and counts in full, which costs a reload, never a missed
78
+ * one.
79
+ *
80
+ * @example
81
+ * export const RoleSchema = extensibleEnum(z.enum(["admin", "member"]));
82
+ */
83
+ export declare const extensibleEnum: <TSchema extends z.ZodEnum>(schema: TSchema) => TSchema;
@@ -43,3 +43,39 @@ export const readApiSignature = async (signatures, apiName) => {
43
43
  }
44
44
  return signature;
45
45
  };
46
+ /**
47
+ * The metadata key extensibleEnum() sets and the digest reads. Namespaced,
48
+ * because zod writes metadata into the JSON Schema it emits, so the key shows
49
+ * up in any schema an app converts for itself, where OpenAPI's own
50
+ * `x-extensible-enum` means something else (the values, in place of `enum`).
51
+ */
52
+ export const EXTENSIBLE_ENUM_META_KEY = "x-lambder-extensible-enum";
53
+ /**
54
+ * Marks an enum whose clients tolerate a value they were not built with, so
55
+ * its list of values stays out of the signature of every endpoint that
56
+ * returns it. Adding a role, a status or a locale to a list that rides in a
57
+ * widely returned payload (a session, a profile) then reloads only the
58
+ * clients that send the list back, not every client that reads it.
59
+ *
60
+ * Where the enum is input its values still count: a value dropped from the
61
+ * list is a request an older client may still send and the server now
62
+ * refuses, so that endpoint's clients must reload. Everything else about the
63
+ * schema is untouched: its type, its validation on both sides, and what it
64
+ * is everywhere outside the digest.
65
+ *
66
+ * The mark is a promise the schema makes for its readers, and nothing checks
67
+ * it. A client that switches over every value with no fallback, or indexes a
68
+ * map by one, renders a value it does not know as nothing, or throws. Mark
69
+ * only a list every reader handles that way on purpose.
70
+ *
71
+ * It is zod metadata (`.meta()`), which zod keeps in one registry on
72
+ * globalThis, so an enum marked in a shared package is read by the digest
73
+ * even when the server resolves another copy of zod. A schema derived from a
74
+ * marked enum by rebuilding it (`z.enum(marked.options)`, `.exclude()`)
75
+ * carries no mark and counts in full, which costs a reload, never a missed
76
+ * one.
77
+ *
78
+ * @example
79
+ * export const RoleSchema = extensibleEnum(z.enum(["admin", "member"]));
80
+ */
81
+ export const extensibleEnum = (schema) => schema.meta({ [EXTENSIBLE_ENUM_META_KEY]: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lambder",
3
- "version": "7.2.1",
3
+ "version": "7.2.3",
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": [