lambder 14.1.1 → 15.0.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 +429 -0
- package/README.md +14 -17
- package/dist/api/LambderApiDeclarations.d.ts +11 -5
- package/dist/api/LambderApiDefinition.d.ts +2 -1
- package/dist/api/LambderApiEnvelope.d.ts +2 -2
- package/dist/api/LambderApiEnvelope.js +8 -2
- package/dist/api/LambderApiGuards.d.ts +44 -5
- package/dist/api/LambderApiGuards.js +27 -0
- package/dist/api/LambderApiIdempotency.d.ts +14 -7
- package/dist/api/LambderApiIdempotency.js +39 -27
- package/dist/api/LambderApiPipeline.d.ts +25 -11
- package/dist/api/LambderApiPipeline.js +34 -17
- package/dist/api/LambderApiRateLimits.d.ts +28 -21
- package/dist/api/LambderApiRateLimits.js +55 -35
- package/dist/api/LambderApiRefusals.d.ts +10 -4
- package/dist/api/LambderApiRefusals.js +31 -5
- package/dist/api/LambderApiRequest.d.ts +21 -1
- package/dist/api/LambderApiRequest.js +31 -2
- package/dist/build/checkApiRefusals.d.ts +8 -3
- package/dist/build/checkApiRefusals.js +80 -29
- package/dist/build/contractPrinterProcess.d.ts +11 -0
- package/dist/build/contractPrinterProcess.js +17 -0
- package/dist/build/generateApiFiles.d.ts +26 -5
- package/dist/build/generateApiFiles.js +68 -7
- package/dist/build/writeApiSchemas.d.ts +109 -0
- package/dist/build/writeApiSchemas.js +308 -0
- package/dist/build.d.ts +3 -1
- package/dist/build.js +2 -0
- package/dist/client/LambderCaller.d.ts +25 -11
- package/dist/client/LambderCaller.js +57 -49
- package/dist/client/LambderUploadRunner.d.ts +55 -27
- package/dist/client/LambderUploadRunner.js +89 -34
- package/dist/client.d.ts +6 -6
- package/dist/client.js +2 -2
- package/dist/core/Lambder.d.ts +31 -21
- package/dist/core/Lambder.js +79 -23
- package/dist/core/LambderCallSummary.d.ts +1 -1
- package/dist/core/LambderContext.d.ts +59 -7
- package/dist/core/LambderContext.js +31 -15
- package/dist/core/LambderCrashHandling.d.ts +5 -0
- package/dist/core/LambderCrashHandling.js +11 -1
- package/dist/core/LambderCreateOptions.d.ts +28 -17
- package/dist/core/LambderCreateOptions.js +14 -7
- package/dist/core/LambderResolver.d.ts +3 -2
- package/dist/core/LambderResponseBuilder.d.ts +20 -9
- package/dist/core/LambderResponseBuilder.js +39 -8
- package/dist/core/LambderTemplatingEngine.d.ts +24 -6
- package/dist/core/LambderTemplatingEngine.js +23 -3
- package/dist/index.d.ts +15 -12
- package/dist/index.js +7 -5
- package/dist/invoke/LambderInvokeCaller.d.ts +25 -4
- package/dist/invoke/LambderInvokeCaller.js +47 -9
- package/dist/invoke/LambderInvokeOutcome.d.ts +7 -6
- package/dist/mock/LambderMockAdapterDoor.d.ts +40 -0
- package/dist/mock/LambderMockAdapterDoor.js +10 -0
- package/dist/mock/LambderMockApiSchemas.d.ts +28 -0
- package/dist/mock/LambderMockApiSchemas.js +150 -0
- package/dist/mock/LambderMockApp.d.ts +56 -48
- package/dist/mock/LambderMockApp.js +107 -62
- package/dist/mock/LambderMockBrowserCookies.js +18 -5
- package/dist/mock/LambderMockCreateOptions.d.ts +51 -4
- package/dist/mock/LambderMockFailureInjector.d.ts +12 -3
- package/dist/mock/LambderMockFailureInjector.js +12 -8
- package/dist/mock/LambderMockTypes.d.ts +8 -4
- package/dist/mock/lambderMockInvokeTransport.js +2 -9
- package/dist/mock/lambderMockMswHandler.d.ts +19 -13
- package/dist/mock/lambderMockMswHandler.js +25 -22
- package/dist/mock.d.ts +1 -0
- package/dist/secrets/LambderOneShotSecrets.d.ts +1 -1
- package/dist/secrets/LambderOneShotSecrets.js +6 -36
- package/dist/secrets/LambderPasswordHasher.d.ts +56 -0
- package/dist/secrets/LambderPasswordHasher.js +168 -0
- package/dist/session/LambderSessionController.d.ts +37 -4
- package/dist/session/LambderSessionController.js +36 -6
- package/dist/session/LambderSessionManager.d.ts +33 -3
- package/dist/session/LambderSessionManager.js +55 -16
- package/dist/shared/LambderHtml.d.ts +23 -4
- package/dist/shared/LambderHtml.js +17 -5
- package/dist/shared/LambderI18n.d.ts +106 -16
- package/dist/shared/LambderI18n.js +259 -74
- package/dist/shared/contracts/LambderCache.d.ts +14 -1
- package/dist/shared/contracts/LambderRateLimiter.d.ts +5 -6
- package/dist/shared/transport/LambderApiTransport.d.ts +3 -2
- package/dist/shared/util/LambderBackoffTimer.d.ts +24 -13
- package/dist/shared/util/LambderBackoffTimer.js +40 -12
- package/dist/shared/util/LambderCallAbort.d.ts +22 -6
- package/dist/shared/util/LambderCallAbort.js +37 -9
- package/dist/shared/util/LambderErrorReportMark.d.ts +4 -0
- package/dist/shared/util/LambderErrorReportMark.js +22 -0
- package/dist/shared/util/LambderKeyFieldDigest.d.ts +46 -0
- package/dist/shared/util/LambderKeyFieldDigest.js +75 -0
- package/dist/shared/util/LambderSignedClaims.d.ts +19 -0
- package/dist/shared/util/LambderSignedClaims.js +54 -0
- package/dist/shared/util/LambderSwappableInstances.d.ts +29 -0
- package/dist/shared/util/LambderSwappableInstances.js +54 -0
- package/dist/shared/util/LambderTestingDoors.d.ts +6 -2
- package/dist/shared/util/LambderTestingDoors.js +6 -2
- package/dist/shared/util/LambderTextDigest.d.ts +5 -5
- package/dist/shared/util/LambderTextDigest.js +5 -5
- package/dist/shared/wire/LambderApiContract.d.ts +1 -1
- package/dist/shared/wire/LambderApiOptionEntries.d.ts +9 -1
- package/dist/shared/wire/LambderApiOptionEntries.js +15 -0
- package/dist/shared/wire/LambderApiOptionValues.d.ts +3 -2
- package/dist/shared/wire/LambderApiOutcome.d.ts +37 -19
- package/dist/shared/wire/LambderApiOutcome.js +1 -1
- package/dist/shared/wire/LambderApiRefusal.d.ts +39 -12
- package/dist/shared/wire/LambderApiRefusal.js +3 -3
- package/dist/shared/wire/LambderApiSchemaEntries.d.ts +21 -0
- package/dist/shared/wire/LambderApiSchemaEntries.js +18 -0
- package/dist/shared/wire/LambderCallOptions.d.ts +3 -3
- package/dist/shared/wire/LambderCallOutcome.d.ts +2 -1
- package/dist/shared/wire/LambderCallOutcome.js +3 -2
- package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +5 -3
- package/dist/shared/wire/LambderIdempotencyKeyScope.js +5 -3
- package/dist/shared/wire/LambderInvokeApiId.d.ts +3 -3
- package/dist/shared/wire/LambderInvokeApiId.js +3 -3
- package/dist/shared/wire/LambderOutcomeAssertions.d.ts +7 -6
- package/dist/shared/wire/LambderOutcomeAssertions.js +1 -1
- package/dist/stores/LambderCacheValues.d.ts +2 -5
- package/dist/stores/LambderDdbCache.d.ts +15 -9
- package/dist/stores/LambderDdbCache.js +25 -6
- package/dist/stores/LambderDdbIdempotencyStore.d.ts +6 -4
- package/dist/stores/LambderDdbIdempotencyStore.js +32 -52
- package/dist/stores/LambderDdbOneShotSecretStore.d.ts +11 -0
- package/dist/stores/LambderDdbOneShotSecretStore.js +17 -5
- package/dist/stores/LambderDdbSdk.d.ts +59 -29
- package/dist/stores/LambderDdbSdk.js +116 -44
- package/dist/stores/LambderDdbSessionStore.d.ts +26 -11
- package/dist/stores/LambderDdbSessionStore.js +133 -68
- package/dist/stores/LambderMemoryCache.d.ts +2 -2
- package/dist/stores/LambderS3FileSource.d.ts +3 -2
- package/dist/stores/LambderS3FileSource.js +11 -13
- package/dist/stores/LambderS3UploadBucket.d.ts +12 -0
- package/dist/stores/LambderS3UploadBucket.js +23 -0
- package/dist/stores/LambderStorageBackedCache.d.ts +2 -2
- package/dist/stores/LambderStoredText.d.ts +26 -0
- package/dist/stores/LambderStoredText.js +25 -0
- package/dist/testing/LambderConformanceRunner.d.ts +9 -2
- package/dist/testing/LambderConformanceRunner.js +9 -2
- package/dist/testing/LambderTestApp.d.ts +65 -7
- package/dist/testing/LambderTestApp.js +101 -6
- package/dist/testing/LambderTestVisitor.d.ts +43 -12
- package/dist/testing/LambderTestVisitor.js +35 -11
- package/dist/testing/bootLambdaPackage.d.ts +105 -0
- package/dist/testing/bootLambdaPackage.js +265 -0
- package/dist/testing/lambdaPackageBootProcess.d.ts +39 -0
- package/dist/testing/lambdaPackageBootProcess.js +130 -0
- package/dist/testing/lambderCacheConformance.d.ts +26 -0
- package/dist/testing/lambderCacheConformance.js +287 -0
- package/dist/testing/lambderCacheStorageConformance.d.ts +25 -0
- package/dist/testing/lambderCacheStorageConformance.js +159 -0
- package/dist/testing/lambderSessionStoreConformance.d.ts +3 -3
- package/dist/testing/lambderSessionStoreConformance.js +3 -3
- package/dist/testing.d.ts +7 -1
- package/dist/testing.js +5 -0
- package/package.json +1 -6
- package/dist/shared/util/boundKeyField.d.ts +0 -20
- package/dist/shared/util/boundKeyField.js +0 -34
|
@@ -171,9 +171,15 @@ export default class LambderSessionManager<SessionData = any> {
|
|
|
171
171
|
*/
|
|
172
172
|
private hashToken;
|
|
173
173
|
createSession(sessionKey: string, data?: SessionData, ttlInSeconds?: number, options?: {
|
|
174
|
-
/** The data refresh deadline to start with
|
|
174
|
+
/** The data refresh deadline to start with, in epoch seconds. Default: dataRefresh.ttlSeconds from now. */
|
|
175
175
|
dataExpiresAt?: number;
|
|
176
176
|
}): Promise<LambderCreatedSession<SessionData>>;
|
|
177
|
+
/**
|
|
178
|
+
* Writes a new record holding `data` under fresh tokens, and hands it
|
|
179
|
+
* back with `served` as its data: the schema's reading of what the record
|
|
180
|
+
* holds, which createSession has checked and a rotation carries over.
|
|
181
|
+
*/
|
|
182
|
+
private mintSession;
|
|
177
183
|
/**
|
|
178
184
|
* Writes new session data onto the record, and nothing else: the expiry
|
|
179
185
|
* slides only in renewSession, which also re-issues the cookies, so a
|
|
@@ -216,8 +222,16 @@ export default class LambderSessionManager<SessionData = any> {
|
|
|
216
222
|
* when the session is over: a refresh said so (a deleted or disabled
|
|
217
223
|
* login), or the record was deleted while this request read it (a
|
|
218
224
|
* logout, a password change), which a renewal must not undo.
|
|
225
|
+
*
|
|
226
|
+
* `refreshData: true` renews the data on this read whatever its
|
|
227
|
+
* deadline, for a read that wants it fresh (a page load): the callback
|
|
228
|
+
* runs once and its result shares the read's one write, where a read
|
|
229
|
+
* followed by refreshSessionData would run both twice whenever the data
|
|
230
|
+
* was due. Requires dataRefresh, and throws without it.
|
|
219
231
|
*/
|
|
220
|
-
renewSession(session: LambderSessionRecord<SessionData
|
|
232
|
+
renewSession(session: LambderSessionRecord<SessionData>, options?: {
|
|
233
|
+
refreshData?: boolean;
|
|
234
|
+
}): Promise<LambderSessionRecord<SessionData> | null>;
|
|
221
235
|
/**
|
|
222
236
|
* Data about to be stored, held to the schema: what a read of it will
|
|
223
237
|
* serve, or the error `refused` builds from where it does not match.
|
|
@@ -297,7 +311,7 @@ export default class LambderSessionManager<SessionData = any> {
|
|
|
297
311
|
* than landing data derived before the change. Requires dataRefresh to
|
|
298
312
|
* be configured.
|
|
299
313
|
*/
|
|
300
|
-
expireSessionDataAllByKey(sessionKey: string): Promise<
|
|
314
|
+
expireSessionDataAllByKey(sessionKey: string): Promise<void>;
|
|
301
315
|
/**
|
|
302
316
|
* Replaces the session with a new one under new tokens, carrying over
|
|
303
317
|
* the data as the delete removed it rather than as this request read
|
|
@@ -319,6 +333,22 @@ export default class LambderSessionManager<SessionData = any> {
|
|
|
319
333
|
* marked by expireSessionDataAllByKey while the new record was being
|
|
320
334
|
* written may have passed it by, and due, its next read renews the data
|
|
321
335
|
* from the source of truth either way.
|
|
336
|
+
*
|
|
337
|
+
* The record is read first, because what the request holds is the
|
|
338
|
+
* schema's reading of the data, which a schema that strips keys or
|
|
339
|
+
* transforms is not the data the record holds. The new record holds the
|
|
340
|
+
* stored data, every comparison is between stored data, and the request
|
|
341
|
+
* is handed the schema's reading of what the new record holds, as a read
|
|
342
|
+
* would hand it.
|
|
322
343
|
*/
|
|
323
344
|
regenerateSession(session: LambderSessionRecord<SessionData>): Promise<LambderCreatedSession<SessionData> | null>;
|
|
345
|
+
/**
|
|
346
|
+
* Data a rotation carries over, as the request is handed it: the
|
|
347
|
+
* schema's reading of it. It is the record's own data rather than the
|
|
348
|
+
* app's, so it is not refused as a write would be. Where the schema
|
|
349
|
+
* refuses it, the request keeps the data it already held, and the new
|
|
350
|
+
* record's next read meets it as every read meets such data: refreshed
|
|
351
|
+
* with dataRefresh (the new record starts due), ended without.
|
|
352
|
+
*/
|
|
353
|
+
private carriedDataOf;
|
|
324
354
|
}
|
|
@@ -185,6 +185,14 @@ export default class LambderSessionManager {
|
|
|
185
185
|
// Refused for the same reason: data the schema refuses would end the
|
|
186
186
|
// session on its first read, a sign-out right after the sign-in.
|
|
187
187
|
const served = this.servedDataOf(data, (issues) => new Error(`Lambder: createSession was given data that does not match session.dataSchema (${issues}).`));
|
|
188
|
+
return await this.mintSession(sessionKey, data, served, ttlInSeconds, options?.dataExpiresAt);
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Writes a new record holding `data` under fresh tokens, and hands it
|
|
192
|
+
* back with `served` as its data: the schema's reading of what the record
|
|
193
|
+
* holds, which createSession has checked and a rotation carries over.
|
|
194
|
+
*/
|
|
195
|
+
async mintSession(sessionKey, data, served, ttlInSeconds, dataExpiresAt) {
|
|
188
196
|
const sessionKeyHash = await this.sessionKeyHashOf(sessionKey);
|
|
189
197
|
// The sort-key SECRET goes to the client; only its hash becomes the
|
|
190
198
|
// store's range key, so the store never contains a usable token.
|
|
@@ -200,7 +208,7 @@ export default class LambderSessionManager {
|
|
|
200
208
|
csrfTokenHash: await this.hashToken(csrfToken),
|
|
201
209
|
sessionKey, data,
|
|
202
210
|
createdAt, lastAccessedAt, expiresAt, ttlInSeconds,
|
|
203
|
-
...(this.dataRefresh ? { dataExpiresAt:
|
|
211
|
+
...(this.dataRefresh ? { dataExpiresAt: dataExpiresAt ?? (createdAt + this.dataRefresh.ttlSeconds) } : {}),
|
|
204
212
|
dataVersion: 0,
|
|
205
213
|
};
|
|
206
214
|
await this.store.create(session);
|
|
@@ -320,8 +328,16 @@ export default class LambderSessionManager {
|
|
|
320
328
|
* when the session is over: a refresh said so (a deleted or disabled
|
|
321
329
|
* login), or the record was deleted while this request read it (a
|
|
322
330
|
* logout, a password change), which a renewal must not undo.
|
|
331
|
+
*
|
|
332
|
+
* `refreshData: true` renews the data on this read whatever its
|
|
333
|
+
* deadline, for a read that wants it fresh (a page load): the callback
|
|
334
|
+
* runs once and its result shares the read's one write, where a read
|
|
335
|
+
* followed by refreshSessionData would run both twice whenever the data
|
|
336
|
+
* was due. Requires dataRefresh, and throws without it.
|
|
323
337
|
*/
|
|
324
|
-
async renewSession(session) {
|
|
338
|
+
async renewSession(session, options = {}) {
|
|
339
|
+
if (options.refreshData && !this.dataRefresh)
|
|
340
|
+
throw new Error("dataRefresh is not configured. Pass session.dataRefresh at creation to enable.");
|
|
325
341
|
const now = Math.floor(Date.now() / 1000);
|
|
326
342
|
const refreshed = {};
|
|
327
343
|
const slid = {};
|
|
@@ -337,10 +353,10 @@ export default class LambderSessionManager {
|
|
|
337
353
|
// once a refresh ran, the refresh's.
|
|
338
354
|
let served = read?.success ? read.data : session.data;
|
|
339
355
|
// Renew session.data once its shelf life has passed (opt-in
|
|
340
|
-
// dataRefresh),
|
|
341
|
-
// record created while dataRefresh was off
|
|
342
|
-
// it renews on its first read.
|
|
343
|
-
if (this.dataRefresh && (read?.success === false || (session.dataExpiresAt ?? 0) <= now)) {
|
|
356
|
+
// dataRefresh), at once when it does not match the schema, or when
|
|
357
|
+
// the read asks for it. A record created while dataRefresh was off
|
|
358
|
+
// has no dataExpiresAt, so it renews on its first read.
|
|
359
|
+
if (this.dataRefresh && (options.refreshData === true || read?.success === false || (session.dataExpiresAt ?? 0) <= now)) {
|
|
344
360
|
let newData;
|
|
345
361
|
try {
|
|
346
362
|
newData = await this.dataRefresh.refresh(session);
|
|
@@ -573,7 +589,6 @@ export default class LambderSessionManager {
|
|
|
573
589
|
const now = Math.floor(Date.now() / 1000);
|
|
574
590
|
// An update, so a session deleted in between stays deleted.
|
|
575
591
|
await this.forEachSessionOf(sessionKeyHash, (secretHash) => this.store.update(sessionKeyHash, secretHash, { dataExpiresAt: now }));
|
|
576
|
-
return true;
|
|
577
592
|
}
|
|
578
593
|
;
|
|
579
594
|
/**
|
|
@@ -597,29 +612,53 @@ export default class LambderSessionManager {
|
|
|
597
612
|
* marked by expireSessionDataAllByKey while the new record was being
|
|
598
613
|
* written may have passed it by, and due, its next read renews the data
|
|
599
614
|
* from the source of truth either way.
|
|
615
|
+
*
|
|
616
|
+
* The record is read first, because what the request holds is the
|
|
617
|
+
* schema's reading of the data, which a schema that strips keys or
|
|
618
|
+
* transforms is not the data the record holds. The new record holds the
|
|
619
|
+
* stored data, every comparison is between stored data, and the request
|
|
620
|
+
* is handed the schema's reading of what the new record holds, as a read
|
|
621
|
+
* would hand it.
|
|
600
622
|
*/
|
|
601
623
|
async regenerateSession(session) {
|
|
602
624
|
if (!session)
|
|
603
625
|
throw new Error("Invalid session");
|
|
604
626
|
const now = Math.floor(Date.now() / 1000);
|
|
605
|
-
const
|
|
627
|
+
const stored = await this.store.get(session.sessionKeyHash, session.secretHash);
|
|
628
|
+
if (!stored || stored.expiresAt <= now)
|
|
629
|
+
return null;
|
|
630
|
+
const created = await this.mintSession(session.sessionKey, stored.data, this.carriedDataOf(stored.data, session), session.ttlInSeconds, this.dataRefresh ? now : undefined);
|
|
606
631
|
const replacement = created.session;
|
|
607
632
|
const removed = await this.store.delete(session.sessionKeyHash, session.secretHash);
|
|
608
633
|
if (!removed || removed.expiresAt <= now) {
|
|
609
634
|
await this.store.delete(replacement.sessionKeyHash, replacement.secretHash);
|
|
610
635
|
return null;
|
|
611
636
|
}
|
|
612
|
-
if (canonicalJson(removed.data) === canonicalJson(
|
|
637
|
+
if (canonicalJson(removed.data) === canonicalJson(stored.data))
|
|
613
638
|
return created;
|
|
614
|
-
// Another request wrote the data
|
|
615
|
-
// moves the replacement's dataVersion one past the one it
|
|
616
|
-
// with, and the record handed back says so: a data write
|
|
617
|
-
// this request names the version the store holds rather
|
|
618
|
-
// stale. A mark landing in between still makes it stale,
|
|
619
|
-
// side (see updateSessionData).
|
|
639
|
+
// Another request wrote the data between this read and the delete.
|
|
640
|
+
// The write moves the replacement's dataVersion one past the one it
|
|
641
|
+
// was created with, and the record handed back says so: a data write
|
|
642
|
+
// later in this request names the version the store holds rather
|
|
643
|
+
// than answer stale. A mark landing in between still makes it stale,
|
|
644
|
+
// the safe side (see updateSessionData).
|
|
620
645
|
if (await this.store.update(replacement.sessionKeyHash, replacement.secretHash, { data: removed.data }) === "missing")
|
|
621
646
|
return null;
|
|
622
|
-
return { ...created, session: { ...replacement, data: removed.data, dataVersion: replacement.dataVersion + 1 } };
|
|
647
|
+
return { ...created, session: { ...replacement, data: this.carriedDataOf(removed.data, session), dataVersion: replacement.dataVersion + 1 } };
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Data a rotation carries over, as the request is handed it: the
|
|
651
|
+
* schema's reading of it. It is the record's own data rather than the
|
|
652
|
+
* app's, so it is not refused as a write would be. Where the schema
|
|
653
|
+
* refuses it, the request keeps the data it already held, and the new
|
|
654
|
+
* record's next read meets it as every read meets such data: refreshed
|
|
655
|
+
* with dataRefresh (the new record starts due), ended without.
|
|
656
|
+
*/
|
|
657
|
+
carriedDataOf(data, session) {
|
|
658
|
+
if (!this.dataSchema)
|
|
659
|
+
return data;
|
|
660
|
+
const read = this.dataSchema.safeParse(data);
|
|
661
|
+
return read.success ? read.data : session.data;
|
|
623
662
|
}
|
|
624
663
|
}
|
|
625
664
|
;
|
|
@@ -65,8 +65,27 @@ export declare const xml: (strings: TemplateStringsArray, ...values: LambderHtml
|
|
|
65
65
|
/** Mark a trusted string as safe (inserted without escaping). Never pass user input. */
|
|
66
66
|
export declare const raw: (value: string) => LambderSafeHtml;
|
|
67
67
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
68
|
+
* Where jsonScript writes its data, and the id it carries. A data block
|
|
69
|
+
* (`application/json`, the default) is found by its id, so it needs one;
|
|
70
|
+
* JSON-LD (`application/ld+json`) is read by whatever crawls the page, so its
|
|
71
|
+
* id is optional.
|
|
71
72
|
*/
|
|
72
|
-
export
|
|
73
|
+
export type LambderJsonScriptOptions = {
|
|
74
|
+
type?: "application/json";
|
|
75
|
+
id: string;
|
|
76
|
+
} | {
|
|
77
|
+
type: "application/ld+json";
|
|
78
|
+
id?: string;
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* JSON in a <script> element, escaped so the payload cannot break out of it:
|
|
82
|
+
* every `<` is written `\u003c` (so no `</script>` or `<!--` can appear), and
|
|
83
|
+
* U+2028 and U+2029 as escapes too, which JSON.parse reads back unchanged.
|
|
84
|
+
*
|
|
85
|
+
* `jsonScript(id, data)` is server-preloaded state as
|
|
86
|
+
* <script type="application/json" id="...">, so an SPA can hydrate without a
|
|
87
|
+
* first fetch; read it with JSON.parse(document.getElementById(id).textContent).
|
|
88
|
+
* `jsonScript({ type: "application/ld+json" }, data)` is structured data for
|
|
89
|
+
* search engines, with the same escaping.
|
|
90
|
+
*/
|
|
91
|
+
export declare const jsonScript: (target: string | LambderJsonScriptOptions, data: unknown) => LambderSafeHtml;
|
|
@@ -169,14 +169,26 @@ export const xml = html;
|
|
|
169
169
|
/** Mark a trusted string as safe (inserted without escaping). Never pass user input. */
|
|
170
170
|
export const raw = (value) => new LambderSafeHtml(value);
|
|
171
171
|
/**
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
172
|
+
* JSON in a <script> element, escaped so the payload cannot break out of it:
|
|
173
|
+
* every `<` is written `\u003c` (so no `</script>` or `<!--` can appear), and
|
|
174
|
+
* U+2028 and U+2029 as escapes too, which JSON.parse reads back unchanged.
|
|
175
|
+
*
|
|
176
|
+
* `jsonScript(id, data)` is server-preloaded state as
|
|
177
|
+
* <script type="application/json" id="...">, so an SPA can hydrate without a
|
|
178
|
+
* first fetch; read it with JSON.parse(document.getElementById(id).textContent).
|
|
179
|
+
* `jsonScript({ type: "application/ld+json" }, data)` is structured data for
|
|
180
|
+
* search engines, with the same escaping.
|
|
175
181
|
*/
|
|
176
|
-
export const jsonScript = (
|
|
182
|
+
export const jsonScript = (target, data) => {
|
|
183
|
+
const { type = "application/json", id } = typeof target === "string" ? { id: target } : target;
|
|
184
|
+
// The type is written into the markup as it is, so only the two it may be get there.
|
|
185
|
+
if (type !== "application/json" && type !== "application/ld+json") {
|
|
186
|
+
throw new Error(`jsonScript: type must be "application/json" or "application/ld+json", got ${JSON.stringify(type)}.`);
|
|
187
|
+
}
|
|
177
188
|
const json = JSON.stringify(data)
|
|
178
189
|
.replace(/</g, "\\u003c")
|
|
179
190
|
.replace(/\u2028/g, "\\u2028")
|
|
180
191
|
.replace(/\u2029/g, "\\u2029");
|
|
181
|
-
|
|
192
|
+
const idAttribute = id === undefined ? "" : ` id="${escapeHtml(id)}"`;
|
|
193
|
+
return new LambderSafeHtml(`<script type="${type}"${idAttribute}>${json}</script>`);
|
|
182
194
|
};
|
|
@@ -18,13 +18,63 @@ export interface LambderLanguageMeta {
|
|
|
18
18
|
/** App-specific extras (e.g. flag emoji). */
|
|
19
19
|
[extra: string]: unknown;
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* The plural categories `Intl.PluralRules` sorts a count into. Which of them
|
|
23
|
+
* a language uses is its own: English uses one and other, Arabic all six,
|
|
24
|
+
* Japanese other alone.
|
|
25
|
+
*/
|
|
26
|
+
export type LambderI18nPluralCategory = "zero" | "one" | "two" | "few" | "many" | "other";
|
|
27
|
+
/**
|
|
28
|
+
* A text that varies with a count: one form per plural category, keyed by
|
|
29
|
+
* category, so `{ one: "{count} item", other: "{count} items" }`.
|
|
30
|
+
* `t(key, { count })` picks the form for the count under the plural rules
|
|
31
|
+
* of the language the text is in, and `other` when the entry lacks the
|
|
32
|
+
* category picked. `other` is required, so there is always a form to fall
|
|
33
|
+
* back to. Which other categories a language uses is the runtime's plural
|
|
34
|
+
* data, which differs between runtimes, so whether an entry holds them all
|
|
35
|
+
* is what `checkPluralCoverage()` reports, in a test or a build, rather than
|
|
36
|
+
* anything that fails where the dictionary arrives.
|
|
37
|
+
*/
|
|
38
|
+
export interface LambderI18nPluralEntry {
|
|
39
|
+
zero?: string;
|
|
40
|
+
one?: string;
|
|
41
|
+
two?: string;
|
|
42
|
+
few?: string;
|
|
43
|
+
many?: string;
|
|
44
|
+
other: string;
|
|
45
|
+
}
|
|
46
|
+
/** What a dictionary holds under one key: a text, or a plural entry. */
|
|
47
|
+
export type LambderI18nDictionaryEntry = string | LambderI18nPluralEntry;
|
|
21
48
|
/** Extracts `{param}` placeholder names from a string literal type. */
|
|
22
49
|
export type LambderI18nExtractParams<S extends string> = S extends `${string}{${infer P}}${infer Rest}` ? P | LambderI18nExtractParams<Rest> : never;
|
|
50
|
+
/** The `{param}` names across every form of a plural entry. */
|
|
51
|
+
type PluralEntryParams<TEntry> = LambderI18nExtractParams<Extract<TEntry[keyof TEntry], string>>;
|
|
23
52
|
/**
|
|
24
53
|
* Typed translator: `t(key)`, and when the key's contract value contains
|
|
25
|
-
* `{tokens}`, a params object with exactly those tokens is required.
|
|
54
|
+
* `{tokens}`, a params object with exactly those tokens is required. A
|
|
55
|
+
* plural key always takes one: a numeric `count`, which picks the form,
|
|
56
|
+
* beside the tokens across its forms.
|
|
57
|
+
*
|
|
58
|
+
* The text test is wrapped (`[...] extends [string]`) so that it does not
|
|
59
|
+
* distribute: a translator stays comparable to `(key: string) => string`,
|
|
60
|
+
* which a cast for keys known only at runtime relies on.
|
|
61
|
+
*/
|
|
62
|
+
export type LambderI18nTranslator<TContract extends Record<string, LambderI18nDictionaryEntry>> = <K extends keyof TContract & string>(...args: [TContract[K]] extends [string] ? LambderI18nExtractParams<TContract[K] & string> extends never ? [key: K] : [key: K, params: Record<LambderI18nExtractParams<TContract[K] & string>, string | number>] : [key: K, params: {
|
|
63
|
+
count: number;
|
|
64
|
+
} & Record<Exclude<PluralEntryParams<TContract[K]>, "count">, string | number>]) => string;
|
|
65
|
+
/**
|
|
66
|
+
* What a language other than the default gives for a contract (the default
|
|
67
|
+
* language's block): a text for each text, and for each plural entry a
|
|
68
|
+
* plural entry of its own, whose forms are its language's. A plain text in
|
|
69
|
+
* place of a plural entry stands for the other form alone: complete for a
|
|
70
|
+
* language whose rules use nothing else (Japanese, say), and a gap that
|
|
71
|
+
* `checkPluralCoverage()` reports for any other. A plural entry where the
|
|
72
|
+
* contract has a text is a type error: `t` takes no count for that key, so
|
|
73
|
+
* no form could be picked.
|
|
26
74
|
*/
|
|
27
|
-
|
|
75
|
+
type TranslationBlock<TContract> = {
|
|
76
|
+
[K in keyof TContract]: TContract[K] extends string ? string : LambderI18nDictionaryEntry;
|
|
77
|
+
};
|
|
28
78
|
/**
|
|
29
79
|
* A language block fetched on demand instead of bundled: a function that
|
|
30
80
|
* resolves to the dictionary, or to a module whose default export is the
|
|
@@ -40,7 +90,7 @@ export type LambderI18nDictionaryLoader<TDict> = () => Promise<TDict | {
|
|
|
40
90
|
* rather than the union, is what lets a compile error name the missing key.
|
|
41
91
|
*/
|
|
42
92
|
type LanguageBlockFor<TBlocks, L, TDict> = L extends keyof TBlocks ? TBlocks[L] extends (...args: never[]) => unknown ? LambderI18nDictionaryLoader<TDict> : TDict : TDict;
|
|
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,
|
|
93
|
+
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, LambderI18nDictionaryEntry>>> {
|
|
44
94
|
/** Master registry of every supported language and its metadata. */
|
|
45
95
|
languages: TLanguages;
|
|
46
96
|
/** Final fallback language. Must be included in `enforced`. */
|
|
@@ -53,16 +103,18 @@ export interface LambderI18nConfig<TLanguages extends Record<string, LambderLang
|
|
|
53
103
|
/**
|
|
54
104
|
* App-wide base dictionary. Strict: every language in `languages` must
|
|
55
105
|
* provide every key (the `defaultLanguage` block is the typed contract).
|
|
56
|
-
*
|
|
106
|
+
* A key's entry is a text, or a plural entry where the text varies with
|
|
107
|
+
* a count. Any language but the default may be a loader instead
|
|
57
108
|
* (`tr: () => import("./tr")`), fetched by `loadLanguage`. The default
|
|
58
109
|
* block stays inline, because every lookup falls back to it.
|
|
59
110
|
*/
|
|
60
111
|
base: TBase & {
|
|
61
|
-
[L in keyof TLanguages]: L extends TDefault ? Record<keyof TBase[TDefault],
|
|
112
|
+
[L in keyof TLanguages]: L extends TDefault ? Record<keyof TBase[TDefault], LambderI18nDictionaryEntry> : LanguageBlockFor<TBase, L, TranslationBlock<TBase[TDefault]>>;
|
|
62
113
|
};
|
|
63
114
|
/**
|
|
64
115
|
* Optional language detector, tried before browser detection. Return a
|
|
65
|
-
* supported code to pick it
|
|
116
|
+
* supported code, in any case, to pick it (it is matched as language tags
|
|
117
|
+
* are, case-insensitively), or anything else to continue the chain:
|
|
66
118
|
* setLanguage override → detectLanguage → browser languages → defaultLanguage.
|
|
67
119
|
*/
|
|
68
120
|
detectLanguage?: (helpers: {
|
|
@@ -71,7 +123,7 @@ export interface LambderI18nConfig<TLanguages extends Record<string, LambderLang
|
|
|
71
123
|
defaultLanguage: TDefault;
|
|
72
124
|
}) => string | null | undefined;
|
|
73
125
|
}
|
|
74
|
-
export interface LambderI18nInstance<TLanguages extends Record<string, LambderLanguageMeta>, TDefault extends keyof TLanguages & string, TEnforced extends readonly (keyof TLanguages & string)[], TContract extends Record<string,
|
|
126
|
+
export interface LambderI18nInstance<TLanguages extends Record<string, LambderLanguageMeta>, TDefault extends keyof TLanguages & string, TEnforced extends readonly (keyof TLanguages & string)[], TContract extends Record<string, LambderI18nDictionaryEntry>> {
|
|
75
127
|
/** Translate using the automatically resolved active language. */
|
|
76
128
|
t: LambderI18nTranslator<TContract>;
|
|
77
129
|
/** Translator bound to an explicit language (per-request backend use). */
|
|
@@ -83,9 +135,9 @@ export interface LambderI18nInstance<TLanguages extends Record<string, LambderLa
|
|
|
83
135
|
* Returns a new instance whose key space = parent keys + new keys.
|
|
84
136
|
*/
|
|
85
137
|
extend<const TExt extends {
|
|
86
|
-
[D in TDefault]: Record<string,
|
|
138
|
+
[D in TDefault]: Record<string, LambderI18nDictionaryEntry>;
|
|
87
139
|
}>(dict: {
|
|
88
|
-
[L in keyof TLanguages]: L extends TDefault ? Record<keyof TExt[TDefault],
|
|
140
|
+
[L in keyof TLanguages]: L extends TDefault ? Record<keyof TExt[TDefault], LambderI18nDictionaryEntry> : LanguageBlockFor<TExt, L, TranslationBlock<TExt[TDefault]>>;
|
|
89
141
|
} & {
|
|
90
142
|
[D in TDefault]: Partial<Record<keyof TContract, never>>;
|
|
91
143
|
} & TExt): LambderI18nInstance<TLanguages, TDefault, TEnforced, TContract & TExt[TDefault]>;
|
|
@@ -97,11 +149,11 @@ export interface LambderI18nInstance<TLanguages extends Record<string, LambderLa
|
|
|
97
149
|
* Any language but the default may be a loader, as in `base`.
|
|
98
150
|
*/
|
|
99
151
|
extendPartial<const TExt extends {
|
|
100
|
-
[D in TDefault]: Record<string,
|
|
152
|
+
[D in TDefault]: Record<string, LambderI18nDictionaryEntry>;
|
|
101
153
|
}>(dict: {
|
|
102
|
-
[E in TEnforced[number]]: E extends TDefault ? Record<keyof TExt[TDefault],
|
|
154
|
+
[E in TEnforced[number]]: E extends TDefault ? Record<keyof TExt[TDefault], LambderI18nDictionaryEntry> : LanguageBlockFor<TExt, E, TranslationBlock<TExt[TDefault]>>;
|
|
103
155
|
} & {
|
|
104
|
-
[L in Exclude<keyof TLanguages & string, TEnforced[number]>]?: LanguageBlockFor<TExt, L, Partial<
|
|
156
|
+
[L in Exclude<keyof TLanguages & string, TEnforced[number]>]?: LanguageBlockFor<TExt, L, Partial<TranslationBlock<TExt[TDefault]>>>;
|
|
105
157
|
} & {
|
|
106
158
|
[D in TDefault]: Partial<Record<keyof TContract, never>>;
|
|
107
159
|
} & TExt): LambderI18nInstance<TLanguages, TDefault, TEnforced, TContract & TExt[TDefault]>;
|
|
@@ -118,8 +170,27 @@ export interface LambderI18nInstance<TLanguages extends Record<string, LambderLa
|
|
|
118
170
|
* `loadLanguage()`, which runs only what is still missing.
|
|
119
171
|
*/
|
|
120
172
|
loadLanguage(code?: keyof TLanguages & string): Promise<void>;
|
|
121
|
-
/**
|
|
122
|
-
|
|
173
|
+
/**
|
|
174
|
+
* Merge additional translations at runtime (e.g. fetched from an API),
|
|
175
|
+
* checked as inline blocks are. Notifies change listeners.
|
|
176
|
+
*/
|
|
177
|
+
registerDictionary(code: keyof TLanguages & string, dict: Record<string, LambderI18nDictionaryEntry>): void;
|
|
178
|
+
/**
|
|
179
|
+
* Whether every plural entry this instance translates with (its own and
|
|
180
|
+
* its parents', in every language) holds a form for each category the
|
|
181
|
+
* language's plural rules use, by this runtime's Intl plural data. Loads
|
|
182
|
+
* every language first, as `loadLanguage` does for each, so entries behind
|
|
183
|
+
* loaders are checked too. Resolves when nothing is missing; rejects with
|
|
184
|
+
* every gap listed by key, language and missing categories, or with the
|
|
185
|
+
* failure of a loader that would not load.
|
|
186
|
+
*
|
|
187
|
+
* For a test or a build step, not for app start: runtimes ship different
|
|
188
|
+
* plural data, and `t` falls back to an entry's `other` form for a
|
|
189
|
+
* category it lacks, so a gap shows the other form rather than failing.
|
|
190
|
+
* An extension sees its parents but not its siblings, so check each leaf
|
|
191
|
+
* extension.
|
|
192
|
+
*/
|
|
193
|
+
checkPluralCoverage(): Promise<void>;
|
|
123
194
|
/**
|
|
124
195
|
* Override the active language (shared with all extended instances), and
|
|
125
196
|
* start its loaders; change listeners fire again when they land. Await
|
|
@@ -150,7 +221,11 @@ export interface LambderI18nInstance<TLanguages extends Record<string, LambderLa
|
|
|
150
221
|
* `i18n.onLanguageChange(() => i18n.applyToDocument())`.
|
|
151
222
|
*/
|
|
152
223
|
applyToDocument(): void;
|
|
153
|
-
/**
|
|
224
|
+
/**
|
|
225
|
+
* Type guard: is this string a supported language code, spelled exactly
|
|
226
|
+
* as registered? The methods that take a code match it in any case, as
|
|
227
|
+
* language tags are compared, and answer it as registered.
|
|
228
|
+
*/
|
|
154
229
|
isLanguageCode(value: string): value is keyof TLanguages & string;
|
|
155
230
|
readonly languages: TLanguages;
|
|
156
231
|
readonly languageList: (keyof TLanguages & string)[];
|
|
@@ -161,6 +236,21 @@ export interface LambderI18nInstance<TLanguages extends Record<string, LambderLa
|
|
|
161
236
|
readonly defaultLanguage: TDefault;
|
|
162
237
|
readonly enforced: TEnforced;
|
|
163
238
|
}
|
|
239
|
+
/**
|
|
240
|
+
* What reading translations takes, over any instance of one contract: `t`,
|
|
241
|
+
* `forLanguage`, the active language and its metadata, change notifications
|
|
242
|
+
* and the registry, without the members that change what an instance
|
|
243
|
+
* answers (`setLanguage`, `resetLanguage`, `loadLanguage`,
|
|
244
|
+
* `registerDictionary`, `applyToDocument`, and `checkPluralCoverage`, which
|
|
245
|
+
* loads every language) or build new ones (`extend`,
|
|
246
|
+
* `extendPartial`). Instances of one contract over different language sets
|
|
247
|
+
* are different types, and every one of them is assignable to this, so code
|
|
248
|
+
* that only reads translations takes this rather than one instance's type:
|
|
249
|
+
* `LambderI18nReadonlyInstance<typeof en>` for the contract of an `en` block.
|
|
250
|
+
* Language codes read as plain strings here, the language set being what it
|
|
251
|
+
* leaves open.
|
|
252
|
+
*/
|
|
253
|
+
export type LambderI18nReadonlyInstance<TContract extends Record<string, LambderI18nDictionaryEntry>> = Pick<LambderI18nInstance<Record<string, LambderLanguageMeta>, string, readonly string[], TContract>, "t" | "forLanguage" | "currentLanguage" | "currentLanguageMeta" | "currentDir" | "currentIntlLocale" | "onLanguageChange" | "isLanguageCode" | "languages" | "languageList" | "languageMetaList" | "defaultLanguage" | "enforced">;
|
|
164
254
|
/** Language codes of an instance: `LambderI18nCodes<typeof i18n>`. */
|
|
165
255
|
export type LambderI18nCodes<T extends {
|
|
166
256
|
languageList: readonly string[];
|
|
@@ -173,5 +263,5 @@ export type LambderI18nKeys<T extends {
|
|
|
173
263
|
export type LambderI18nTranslatorFor<T extends {
|
|
174
264
|
t: unknown;
|
|
175
265
|
}> = T["t"];
|
|
176
|
-
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,
|
|
266
|
+
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, LambderI18nDictionaryEntry>>>(config: LambderI18nConfig<TLanguages, TDefault, TEnforced, TBase>) => LambderI18nInstance<TLanguages, TDefault, TEnforced, TBase[TDefault]>;
|
|
177
267
|
export {};
|