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 +44 -0
- package/README.md +2 -2
- package/dist/api/LambderApiSignature.d.ts +6 -4
- package/dist/api/LambderApiSignature.js +23 -8
- package/dist/client.d.ts +2 -2
- package/dist/client.js +3 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/shared/LambderI18n.d.ts +45 -5
- package/dist/shared/LambderI18n.js +117 -10
- package/dist/shared/wire/LambderApiSignature.d.ts +37 -0
- package/dist/shared/wire/LambderApiSignature.js +36 -0
- package/package.json +1 -1
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
|
|
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
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* the
|
|
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
|
-
*
|
|
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
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* the
|
|
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,
|
|
18
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
132
|
-
|
|
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,
|
|
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,
|
|
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) {
|
|
165
|
-
|
|
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,
|
|
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.
|
|
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": [
|