lambder 11.1.1 → 13.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +217 -0
- package/README.md +25 -15
- package/dist/api/LambderApiAnswer.d.ts +7 -0
- package/dist/api/LambderApiDeclarations.d.ts +194 -0
- package/dist/api/LambderApiDeclarations.js +52 -0
- package/dist/api/LambderApiDefinition.d.ts +1 -1
- package/dist/api/LambderApiEnvelope.js +4 -1
- package/dist/api/LambderApiGuards.d.ts +21 -22
- package/dist/api/LambderApiGuards.js +1 -1
- package/dist/api/LambderApiRateLimits.d.ts +19 -19
- package/dist/api/LambderApiRateLimits.js +1 -1
- package/dist/api/LambderApiRefusals.d.ts +22 -0
- package/dist/api/LambderApiRefusals.js +13 -0
- package/dist/api/LambderApiRequest.d.ts +15 -6
- package/dist/api/LambderApiRequest.js +8 -9
- package/dist/build/generateApiFiles.d.ts +60 -0
- package/dist/build/generateApiFiles.js +85 -0
- package/dist/build/generatedTables.d.ts +2 -1
- package/dist/build/generatedTables.js +1 -1
- package/dist/build.d.ts +4 -1
- package/dist/build.js +3 -1
- package/dist/client/LambderCaller.d.ts +36 -23
- package/dist/client/LambderCaller.js +88 -67
- package/dist/client/lambderFetchTransport.js +2 -1
- package/dist/client.d.ts +3 -1
- package/dist/client.js +2 -0
- package/dist/core/Lambder.d.ts +232 -92
- package/dist/core/Lambder.js +400 -103
- package/dist/core/LambderCallSummary.d.ts +43 -0
- package/dist/core/LambderCallSummary.js +10 -0
- package/dist/core/LambderContext.d.ts +15 -2
- package/dist/core/LambderContext.js +32 -9
- package/dist/core/LambderCreateOptions.d.ts +78 -64
- package/dist/core/LambderCreateOptions.js +39 -21
- package/dist/core/LambderInvocationScope.d.ts +25 -0
- package/dist/core/LambderInvocationScope.js +21 -0
- package/dist/core/LambderResponse.d.ts +11 -0
- package/dist/core/LambderResponse.js +12 -0
- package/dist/core/LambderResponseBuilder.js +5 -1
- package/dist/index.d.ts +13 -7
- package/dist/index.js +3 -1
- package/dist/invoke/LambderInvokeCaller.d.ts +48 -31
- package/dist/invoke/LambderInvokeCaller.js +110 -94
- package/dist/invoke/LambderLambdaEvent.d.ts +2 -1
- package/dist/invoke/LambderLambdaEvent.js +8 -3
- package/dist/invoke/lambderHandlerTransport.js +2 -1
- package/dist/mock/LambderMockApp.d.ts +39 -33
- package/dist/mock/LambderMockApp.js +66 -55
- package/dist/mock/LambderMockCreateOptions.d.ts +8 -10
- package/dist/mock/LambderMockTypes.d.ts +44 -7
- package/dist/mock/lambderMockInvokeTransport.d.ts +9 -1
- package/dist/mock/lambderMockInvokeTransport.js +15 -7
- package/dist/mock/lambderMockMswHandler.js +8 -4
- package/dist/mock.d.ts +2 -2
- package/dist/session/LambderSessionManager.d.ts +46 -3
- package/dist/session/LambderSessionManager.js +62 -11
- package/dist/shared/LambderI18n.js +49 -4
- package/dist/shared/contracts/LambderRateLimiter.d.ts +2 -2
- package/dist/shared/contracts/LambderRateLimiter.js +2 -2
- package/dist/shared/transport/LambderApiTransport.d.ts +3 -2
- package/dist/shared/transport/LambderApiTransport.js +2 -3
- package/dist/shared/transport/lambderCookieJarTransport.js +2 -1
- package/dist/shared/util/LambderInvocationScope.d.ts +10 -0
- package/dist/shared/util/LambderInvocationScope.js +5 -0
- package/dist/shared/util/LambderNamedMaps.d.ts +22 -0
- package/dist/shared/util/LambderNamedMaps.js +19 -0
- package/dist/shared/util/LambderTypeUtilities.d.ts +1 -1
- package/dist/shared/wire/LambderApiContract.d.ts +4 -18
- package/dist/shared/wire/LambderApiContract.js +1 -1
- package/dist/shared/wire/LambderApiGroupCalls.d.ts +31 -0
- package/dist/shared/wire/LambderApiGroupCalls.js +68 -0
- package/dist/shared/wire/LambderApiNames.d.ts +55 -0
- package/dist/shared/wire/LambderApiNames.js +81 -0
- package/dist/shared/wire/LambderCallOutcome.d.ts +27 -0
- package/dist/shared/wire/LambderCallOutcome.js +47 -0
- package/dist/shared/wire/LambderInvokeApiId.d.ts +10 -0
- package/dist/shared/wire/LambderInvokeApiId.js +10 -0
- package/dist/shared/wire/LambderUploadSchemas.js +2 -1
- package/dist/stores/LambderDdbRateLimiter.d.ts +24 -16
- package/dist/stores/LambderDdbRateLimiter.js +64 -45
- package/dist/stores/LambderMemoryRateLimiter.d.ts +9 -4
- package/dist/stores/LambderMemoryRateLimiter.js +20 -15
- package/dist/testing/LambderTestApp.d.ts +18 -5
- package/dist/testing/LambderTestApp.js +21 -5
- package/dist/testing/LambderTestVisitor.d.ts +16 -8
- package/dist/testing/LambderTestVisitor.js +33 -25
- package/package.json +1 -1
package/dist/mock.d.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
export { LambderMockApp, initLambderMock } from "./mock/LambderMockApp.js";
|
|
10
10
|
export { LambderMockTransportError } from "./mock/LambderMockFailureInjector.js";
|
|
11
11
|
export type { LambderMockAppOptions, LambderMockSessionsOptions, LambderMockIdempotencyOptions, LambderMockInvalidInputAnswer, LambderMockTransport, LambderMockTransportOptions } from "./mock/LambderMockCreateOptions.js";
|
|
12
|
-
export type { LambderMockCallContext, LambderMockSessionCallContext, LambderMockContext, LambderMockRefusalsOf, LambderMockGuards, LambderMockHandler, LambderMockEntry, LambderMockEntryOptions, LambderMockEntryInput, LambderMockSlice, LambderMockRestEntry, LambderMockRegistryCheck, LambderMockMissingNames, LambderMockStrayNames, LambderMockDuplicateNames,
|
|
12
|
+
export type { LambderMockCallContext, LambderMockSessionCallContext, LambderMockContext, LambderMockRefusalsOf, LambderMockGuards, LambderMockHandler, LambderMockEntry, LambderMockEntryOptions, LambderMockEntryInput, LambderMockNotMockedInput, LambderMockSessionGuardsCheck, LambderMockSlice, LambderMockRestEntry, LambderMockRegistryCheck, LambderMockMissingNames, LambderMockStrayNames, LambderMockDuplicateNames, LambderMockLatency, LambderMockFailure, LambderMockFailureReason, LambderMockOutcome, LambderMockCallEvent, LambderMockRequestEvent, LambderMockResponseEvent, LambderMockCallRecord, LambderMockListener, LambderMockRateLimitPolicies, LambderMockInputOf, LambderMockOutputOf, LambderMockOverride, } from "./mock/LambderMockTypes.js";
|
|
13
13
|
export { lambderMockPoliciesFrom } from "./mock/lambderMockPoliciesFrom.js";
|
|
14
14
|
export type { LambderCustomKeyedPolicyNames, LambderMockPolicyKeys } from "./mock/lambderMockPoliciesFrom.js";
|
|
15
15
|
export type { LambderMockGuardShapeOf } from "./mock/LambderMockCreateOptions.js";
|
|
@@ -21,7 +21,7 @@ export { LambderMemoryUploadBucket } from "./stores/LambderMemoryUploadBucket.js
|
|
|
21
21
|
export type { LambderMemoryUploadBucketOptions, LambderMemoryUploadObject } from "./stores/LambderMemoryUploadBucket.js";
|
|
22
22
|
export { lambderMockUploadMswHandler } from "./mock/lambderMockUploadMswHandler.js";
|
|
23
23
|
export { lambderMockInvokeTransport } from "./mock/lambderMockInvokeTransport.js";
|
|
24
|
-
export type { LambderMockInvokeEvent, LambderMockInvokeResult } from "./mock/lambderMockInvokeTransport.js";
|
|
24
|
+
export type { LambderMockInvokeEvent, LambderMockInvokeResult, LambderMockInvokeTransportOptions } from "./mock/lambderMockInvokeTransport.js";
|
|
25
25
|
export { LambderCookieJar } from "./shared/transport/LambderCookieJar.js";
|
|
26
26
|
export { lambderCookieJarTransport } from "./shared/transport/lambderCookieJarTransport.js";
|
|
27
27
|
export type { LambderApiTransport, LambderApiTransportRequest } from "./shared/transport/LambderApiTransport.js";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
1
2
|
import type { LambderSessionCrypto } from "./LambderSessionCrypto.js";
|
|
2
3
|
import type { LambderSessionRecord, LambderSessionStore } from "../shared/contracts/LambderSessionStore.js";
|
|
3
4
|
import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
|
|
@@ -60,6 +61,39 @@ export declare class LambderSessionDataRefreshError extends Error {
|
|
|
60
61
|
export declare class LambderSessionReadError extends Error {
|
|
61
62
|
constructor(cause: unknown);
|
|
62
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* The shape session.data has, and how it is kept fresh: `dataSchema` alone,
|
|
66
|
+
* or with `dataRefresh`, which then requires it.
|
|
67
|
+
*
|
|
68
|
+
* A record outlives the code that wrote it, so a read checks its data
|
|
69
|
+
* against the schema, and hands the request the schema's output (keys it
|
|
70
|
+
* does not declare stripped). Data that does not match (a record written
|
|
71
|
+
* before a deploy changed the data's shape) is refreshed on that read when
|
|
72
|
+
* dataRefresh is configured, whatever its deadline; without dataRefresh
|
|
73
|
+
* nothing can rebuild it, and the session ends. A refresh's own output is
|
|
74
|
+
* held to the schema too, and one that does not match fails the read as a
|
|
75
|
+
* LambderSessionDataRefreshError.
|
|
76
|
+
*
|
|
77
|
+
* Data is held to the schema where it is written as well: createSession and
|
|
78
|
+
* updateSessionData throw on data it refuses, rather than store a session
|
|
79
|
+
* whose next read would end it. What is stored is the data as given, and
|
|
80
|
+
* what a request is served is the schema's output, so every read of a
|
|
81
|
+
* record parses exactly what its write was checked with.
|
|
82
|
+
*
|
|
83
|
+
* Data a refresh derives from the app's state changes shape as the app
|
|
84
|
+
* does, which is why dataRefresh needs the schema: without it, a record
|
|
85
|
+
* written before the change is served in its old shape until its refresh
|
|
86
|
+
* comes due.
|
|
87
|
+
*/
|
|
88
|
+
export type LambderSessionDataOptions<SessionData = any> = {
|
|
89
|
+
/** The schema of session.data, checked on every read. */
|
|
90
|
+
dataSchema?: z.ZodType<SessionData>;
|
|
91
|
+
dataRefresh?: undefined;
|
|
92
|
+
} | {
|
|
93
|
+
/** The schema of session.data, checked on every read and on every refresh's output. Required beside dataRefresh. */
|
|
94
|
+
dataSchema: z.ZodType<SessionData>;
|
|
95
|
+
dataRefresh: LambderSessionDataRefreshConfig<SessionData>;
|
|
96
|
+
};
|
|
63
97
|
export type LambderSessionManagerOptions<SessionData = any> = {
|
|
64
98
|
/** Where sessions rest: LambderDdbSessionStore, LambderMemorySessionStore, or your own. */
|
|
65
99
|
store: LambderSessionStore<SessionData>;
|
|
@@ -68,10 +102,9 @@ export type LambderSessionManagerOptions<SessionData = any> = {
|
|
|
68
102
|
enableSlidingExpiration?: boolean;
|
|
69
103
|
/** Min seconds between sliding-expiration writes. Default: max(60, 5% of TTL). */
|
|
70
104
|
slidingWriteIntervalSeconds?: number;
|
|
71
|
-
dataRefresh?: LambderSessionDataRefreshConfig<SessionData>;
|
|
72
105
|
/** Hashing and randomness. Default: WebCrypto. */
|
|
73
106
|
crypto?: LambderSessionCrypto;
|
|
74
|
-
}
|
|
107
|
+
} & LambderSessionDataOptions<SessionData>;
|
|
75
108
|
/**
|
|
76
109
|
* Whether a string has the shape a minted session token has:
|
|
77
110
|
* `sessionKeyHash:secret`, both hex, neither longer than 1024 characters.
|
|
@@ -105,8 +138,9 @@ export default class LambderSessionManager<SessionData = any> {
|
|
|
105
138
|
private readonly enableSlidingExpiration;
|
|
106
139
|
private readonly slidingWriteIntervalSeconds;
|
|
107
140
|
private readonly dataRefresh;
|
|
141
|
+
private readonly dataSchema;
|
|
108
142
|
private readonly crypto;
|
|
109
|
-
constructor({ store, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, dataRefresh, crypto, }: LambderSessionManagerOptions<SessionData>);
|
|
143
|
+
constructor({ store, sessionSalt, enableSlidingExpiration, slidingWriteIntervalSeconds, dataRefresh, dataSchema, crypto, }: LambderSessionManagerOptions<SessionData>);
|
|
110
144
|
/** A store this manager's crypto may sit in front of. Asked of every store it is given, the one at creation and a swapped one alike. */
|
|
111
145
|
private assertCryptoFitsStore;
|
|
112
146
|
/**
|
|
@@ -184,6 +218,15 @@ export default class LambderSessionManager<SessionData = any> {
|
|
|
184
218
|
* logout, a password change), which a renewal must not undo.
|
|
185
219
|
*/
|
|
186
220
|
renewSession(session: LambderSessionRecord<SessionData>): Promise<LambderSessionRecord<SessionData> | null>;
|
|
221
|
+
/**
|
|
222
|
+
* Data about to be stored, held to the schema: what a read of it will
|
|
223
|
+
* serve, or the error `refused` builds from where it does not match.
|
|
224
|
+
* Data the schema refuses is the app breaking its own declaration, and
|
|
225
|
+
* storing it would only move the failure to the next read, far from the
|
|
226
|
+
* code that wrote it. The data is stored as given, not as the schema's
|
|
227
|
+
* output, so every later read parses exactly what this check accepted.
|
|
228
|
+
*/
|
|
229
|
+
private servedDataOf;
|
|
187
230
|
/**
|
|
188
231
|
* Runs the dataRefresh callback immediately, regardless of
|
|
189
232
|
* dataExpiresAt, and persists the result onto the same record, over the
|
|
@@ -28,6 +28,11 @@ export class LambderSessionReadError extends Error {
|
|
|
28
28
|
this.name = "LambderSessionReadError";
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
|
+
/** A schema's issues as a message may show them: each path and what was expected there, never the value found. */
|
|
32
|
+
const describeSchemaIssues = (error) => error.issues.slice(0, 5).map((issue) => `${issue.path.length ? issue.path.join(".") : "(the data)"}: ${issue.message}`).join("; ")
|
|
33
|
+
+ (error.issues.length > 5 ? `; and ${error.issues.length - 5} more` : "");
|
|
34
|
+
/** A refresh's output the schema refuses, as the read fails with it. */
|
|
35
|
+
const refreshRefused = (issues) => new LambderSessionDataRefreshError(new Error(`the refreshed data does not match session.dataSchema (${issues})`));
|
|
31
36
|
/**
|
|
32
37
|
* The longest either half of a session token may be. A minted token is two
|
|
33
38
|
* 64-character hex halves; sixteen times that leaves a custom
|
|
@@ -95,8 +100,9 @@ export default class LambderSessionManager {
|
|
|
95
100
|
enableSlidingExpiration;
|
|
96
101
|
slidingWriteIntervalSeconds;
|
|
97
102
|
dataRefresh;
|
|
103
|
+
dataSchema;
|
|
98
104
|
crypto;
|
|
99
|
-
constructor({ store, sessionSalt, enableSlidingExpiration = true, slidingWriteIntervalSeconds, dataRefresh, crypto, }) {
|
|
105
|
+
constructor({ store, sessionSalt, enableSlidingExpiration = true, slidingWriteIntervalSeconds, dataRefresh, dataSchema, crypto, }) {
|
|
100
106
|
this.store = store;
|
|
101
107
|
this.sessionSalt = sessionSalt;
|
|
102
108
|
this.enableSlidingExpiration = enableSlidingExpiration;
|
|
@@ -105,7 +111,16 @@ export default class LambderSessionManager {
|
|
|
105
111
|
: assertPositiveInteger(slidingWriteIntervalSeconds, "session.slidingWriteIntervalSeconds");
|
|
106
112
|
if (dataRefresh)
|
|
107
113
|
assertPositiveInteger(dataRefresh.ttlSeconds, "session.dataRefresh.ttlSeconds");
|
|
114
|
+
if (dataRefresh && !dataSchema) {
|
|
115
|
+
throw new Error("Lambder: session.dataRefresh needs session.dataSchema, the schema session.data has. Data a refresh derives " +
|
|
116
|
+
"changes shape as the app does, and without the schema a record written before the change is served in its " +
|
|
117
|
+
"old shape until its refresh comes due; with it, such a record refreshes on its next read.");
|
|
118
|
+
}
|
|
119
|
+
if (dataSchema !== undefined && typeof dataSchema?.safeParse !== "function") {
|
|
120
|
+
throw new Error("Lambder: session.dataSchema is not a zod schema.");
|
|
121
|
+
}
|
|
108
122
|
this.dataRefresh = dataRefresh ?? null;
|
|
123
|
+
this.dataSchema = dataSchema ?? null;
|
|
109
124
|
this.crypto = crypto ?? new LambderWebCrypto();
|
|
110
125
|
this.assertCryptoFitsStore(store);
|
|
111
126
|
if (typeof sessionSalt !== "string" || sessionSalt.length === 0) {
|
|
@@ -167,6 +182,9 @@ export default class LambderSessionManager {
|
|
|
167
182
|
// logout.
|
|
168
183
|
if (!sessionKey)
|
|
169
184
|
throw new Error("Lambder: createSession sessionKey is empty. It names the subject the session belongs to and partitions the store, so an empty one writes a record no read accepts.");
|
|
185
|
+
// Refused for the same reason: data the schema refuses would end the
|
|
186
|
+
// session on its first read, a sign-out right after the sign-in.
|
|
187
|
+
const served = this.servedDataOf(data, (issues) => new Error(`Lambder: createSession was given data that does not match session.dataSchema (${issues}).`));
|
|
170
188
|
const sessionKeyHash = await this.sessionKeyHashOf(sessionKey);
|
|
171
189
|
// The sort-key SECRET goes to the client; only its hash becomes the
|
|
172
190
|
// store's range key, so the store never contains a usable token.
|
|
@@ -186,7 +204,7 @@ export default class LambderSessionManager {
|
|
|
186
204
|
dataVersion: 0,
|
|
187
205
|
};
|
|
188
206
|
await this.store.create(session);
|
|
189
|
-
return { session, sessionToken, csrfToken };
|
|
207
|
+
return { session: { ...session, data: served }, sessionToken, csrfToken };
|
|
190
208
|
}
|
|
191
209
|
/**
|
|
192
210
|
* Writes new session data onto the record, and nothing else: the expiry
|
|
@@ -217,14 +235,17 @@ export default class LambderSessionManager {
|
|
|
217
235
|
async updateSessionData(session, newData) {
|
|
218
236
|
if (!session)
|
|
219
237
|
throw new Error("Invalid session");
|
|
238
|
+
// Data the schema refuses would end the session on its next read
|
|
239
|
+
// (see createSession), so the write that brings it fails instead.
|
|
240
|
+
const served = this.servedDataOf(newData, (issues) => new Error(`Lambder: updateSessionData was given data that does not match session.dataSchema (${issues}).`));
|
|
220
241
|
const { sessionKeyHash, secretHash } = session;
|
|
221
242
|
if (!this.dataRefresh) {
|
|
222
243
|
const result = await this.store.update(sessionKeyHash, secretHash, { data: newData });
|
|
223
|
-
return result === "missing" ? null : { ...session, data:
|
|
244
|
+
return result === "missing" ? null : { ...session, data: served };
|
|
224
245
|
}
|
|
225
246
|
const result = await this.store.update(sessionKeyHash, secretHash, { data: newData }, { dataVersion: session.dataVersion });
|
|
226
247
|
if (result === "updated")
|
|
227
|
-
return { ...session, data:
|
|
248
|
+
return { ...session, data: served, dataVersion: session.dataVersion + 1 };
|
|
228
249
|
if (result === "missing")
|
|
229
250
|
return null;
|
|
230
251
|
// The data or its deadline was written in between: a revocation was
|
|
@@ -235,7 +256,7 @@ export default class LambderSessionManager {
|
|
|
235
256
|
const now = Math.floor(Date.now() / 1000);
|
|
236
257
|
if (await this.store.update(sessionKeyHash, secretHash, { data: newData, dataExpiresAt: now }) === "missing")
|
|
237
258
|
return null;
|
|
238
|
-
return { ...session, data:
|
|
259
|
+
return { ...session, data: served, dataExpiresAt: now };
|
|
239
260
|
}
|
|
240
261
|
/**
|
|
241
262
|
* The record a token names, read and structurally checked, with nothing
|
|
@@ -304,10 +325,22 @@ export default class LambderSessionManager {
|
|
|
304
325
|
const now = Math.floor(Date.now() / 1000);
|
|
305
326
|
const refreshed = {};
|
|
306
327
|
const slid = {};
|
|
328
|
+
// The record's data checked against the schema (see
|
|
329
|
+
// LambderSessionDataOptions). Without dataRefresh nothing can rebuild
|
|
330
|
+
// data that does not match, so the session ends.
|
|
331
|
+
const read = this.dataSchema ? this.dataSchema.safeParse(session.data) : null;
|
|
332
|
+
if (read && !read.success && !this.dataRefresh) {
|
|
333
|
+
await this.deleteSession(session);
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
// What the request is served: the data as the schema reads it, or,
|
|
337
|
+
// once a refresh ran, the refresh's.
|
|
338
|
+
let served = read?.success ? read.data : session.data;
|
|
307
339
|
// Renew session.data once its shelf life has passed (opt-in
|
|
308
|
-
// dataRefresh)
|
|
309
|
-
//
|
|
310
|
-
|
|
340
|
+
// dataRefresh), or at once when it does not match the schema. A
|
|
341
|
+
// record created while dataRefresh was off has no dataExpiresAt, so
|
|
342
|
+
// it renews on its first read.
|
|
343
|
+
if (this.dataRefresh && (read?.success === false || (session.dataExpiresAt ?? 0) <= now)) {
|
|
311
344
|
let newData;
|
|
312
345
|
try {
|
|
313
346
|
newData = await this.dataRefresh.refresh(session);
|
|
@@ -321,6 +354,7 @@ export default class LambderSessionManager {
|
|
|
321
354
|
await this.deleteSession(session);
|
|
322
355
|
return null;
|
|
323
356
|
}
|
|
357
|
+
served = this.servedDataOf(newData, refreshRefused);
|
|
324
358
|
refreshed.data = newData;
|
|
325
359
|
refreshed.dataExpiresAt = now + this.dataRefresh.ttlSeconds;
|
|
326
360
|
}
|
|
@@ -335,7 +369,7 @@ export default class LambderSessionManager {
|
|
|
335
369
|
slid.expiresAt = now + session.ttlInSeconds;
|
|
336
370
|
}
|
|
337
371
|
}
|
|
338
|
-
const renewed = { ...session, ...refreshed, ...slid };
|
|
372
|
+
const renewed = { ...session, ...refreshed, ...slid, data: served };
|
|
339
373
|
if (refreshed.data === undefined && slid.expiresAt === undefined)
|
|
340
374
|
return renewed;
|
|
341
375
|
// Awaited, so it persists before Lambda freezes. A failing write is
|
|
@@ -361,11 +395,27 @@ export default class LambderSessionManager {
|
|
|
361
395
|
}
|
|
362
396
|
catch (err) {
|
|
363
397
|
console.error(`Lambder session: the renewal write failed, so this session keeps its stored expiry. ${coerceToError(err).message}`);
|
|
364
|
-
return { ...session, ...refreshed };
|
|
398
|
+
return { ...session, ...refreshed, data: served };
|
|
365
399
|
}
|
|
366
400
|
return renewed;
|
|
367
401
|
}
|
|
368
402
|
;
|
|
403
|
+
/**
|
|
404
|
+
* Data about to be stored, held to the schema: what a read of it will
|
|
405
|
+
* serve, or the error `refused` builds from where it does not match.
|
|
406
|
+
* Data the schema refuses is the app breaking its own declaration, and
|
|
407
|
+
* storing it would only move the failure to the next read, far from the
|
|
408
|
+
* code that wrote it. The data is stored as given, not as the schema's
|
|
409
|
+
* output, so every later read parses exactly what this check accepted.
|
|
410
|
+
*/
|
|
411
|
+
servedDataOf(data, refused) {
|
|
412
|
+
if (!this.dataSchema)
|
|
413
|
+
return data;
|
|
414
|
+
const checked = this.dataSchema.safeParse(data);
|
|
415
|
+
if (checked.success)
|
|
416
|
+
return checked.data;
|
|
417
|
+
throw refused(describeSchemaIssues(checked.error));
|
|
418
|
+
}
|
|
369
419
|
/**
|
|
370
420
|
* Runs the dataRefresh callback immediately, regardless of
|
|
371
421
|
* dataExpiresAt, and persists the result onto the same record, over the
|
|
@@ -390,13 +440,14 @@ export default class LambderSessionManager {
|
|
|
390
440
|
await this.deleteSession(session);
|
|
391
441
|
return null;
|
|
392
442
|
}
|
|
443
|
+
const served = this.servedDataOf(newData, refreshRefused);
|
|
393
444
|
const changes = { data: newData, dataExpiresAt: Math.floor(Date.now() / 1000) + this.dataRefresh.ttlSeconds };
|
|
394
445
|
const result = await this.store.update(session.sessionKeyHash, session.secretHash, changes, { dataVersion: session.dataVersion });
|
|
395
446
|
if (result === "missing")
|
|
396
447
|
return null;
|
|
397
448
|
// "stale": served, not written, and the record keeps the version it
|
|
398
449
|
// was read with (see updateSessionData).
|
|
399
|
-
return { ...session, ...changes, ...(result === "updated" ? { dataVersion: session.dataVersion + 1 } : {}) };
|
|
450
|
+
return { ...session, ...changes, data: served, ...(result === "updated" ? { dataVersion: session.dataVersion + 1 } : {}) };
|
|
400
451
|
}
|
|
401
452
|
;
|
|
402
453
|
/**
|
|
@@ -112,6 +112,38 @@ const interpolate = (text, params) => {
|
|
|
112
112
|
return text;
|
|
113
113
|
return text.replace(/\{([^{}]+)\}/g, (token, name) => Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : token);
|
|
114
114
|
};
|
|
115
|
+
/** The `{name}` tokens a text carries, as interpolate reads them. */
|
|
116
|
+
const placeholdersOf = (text) => new Set([...text.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1]));
|
|
117
|
+
/** A set of placeholders as a message shows it: `{count}, {name}`, or "none". */
|
|
118
|
+
const describePlaceholders = (placeholders) => placeholders.size ? [...placeholders].sort().map((name) => `{${name}}`).join(", ") : "none";
|
|
119
|
+
/** How many mismatched translations an error lists before it only counts the rest. */
|
|
120
|
+
const PLACEHOLDER_MISMATCH_LIMIT = 20;
|
|
121
|
+
/**
|
|
122
|
+
* Refuses translations that do not carry the placeholders of the text they
|
|
123
|
+
* translate. The default language's text is the contract the translator's
|
|
124
|
+
* type takes its parameters from, so a translation that drops one loses the
|
|
125
|
+
* value a caller passed, and one that adds one shows its `{token}` as it
|
|
126
|
+
* is. `lookupDefault` answers the default language's text for a key, and a
|
|
127
|
+
* key it has none for is left to the other checks.
|
|
128
|
+
*/
|
|
129
|
+
const assertPlaceholdersKept = (lookupDefault, lang, dict, label) => {
|
|
130
|
+
const mismatches = [];
|
|
131
|
+
for (const [key, text] of Object.entries(dict)) {
|
|
132
|
+
const original = lookupDefault(key);
|
|
133
|
+
if (original === undefined || typeof text !== "string")
|
|
134
|
+
continue;
|
|
135
|
+
const expected = placeholdersOf(original);
|
|
136
|
+
const found = placeholdersOf(text);
|
|
137
|
+
if (expected.size === found.size && [...expected].every((name) => found.has(name)))
|
|
138
|
+
continue;
|
|
139
|
+
mismatches.push(`"${key}" has ${describePlaceholders(found)} where the default language has ${describePlaceholders(expected)}`);
|
|
140
|
+
}
|
|
141
|
+
if (mismatches.length === 0)
|
|
142
|
+
return;
|
|
143
|
+
const listed = mismatches.slice(0, PLACEHOLDER_MISMATCH_LIMIT).map((line) => ` ${line}`);
|
|
144
|
+
const more = mismatches.length > PLACEHOLDER_MISMATCH_LIMIT ? [` ... and ${mismatches.length - PLACEHOLDER_MISMATCH_LIMIT} more`] : [];
|
|
145
|
+
throw new Error([`LambderI18n: in ${label}, these "${lang}" translations carry other placeholders than the default language's text:`, ...listed, ...more].join("\n"));
|
|
146
|
+
};
|
|
115
147
|
const layerLookup = (layer, lang, key) => {
|
|
116
148
|
for (let node = layer; node; node = node.parent) {
|
|
117
149
|
const value = node.dicts[lang]?.[key];
|
|
@@ -133,7 +165,12 @@ const assertInlineDefaultBlock = (dict, defaultLanguage, label) => {
|
|
|
133
165
|
throw new Error(`LambderI18n: ${label} must give the default language "${defaultLanguage}" inline, not as a loader.`);
|
|
134
166
|
}
|
|
135
167
|
};
|
|
136
|
-
|
|
168
|
+
/**
|
|
169
|
+
* A layer over its parent, from a dictionary set as configured. The inline
|
|
170
|
+
* blocks' placeholders are checked here, against the default language's
|
|
171
|
+
* texts the layer reaches; a loader's are checked when it has run.
|
|
172
|
+
*/
|
|
173
|
+
const createLayer = (core, sources, parent, label) => {
|
|
137
174
|
const layer = { dicts: {}, loaders: new Map(), inFlight: new Map(), parent };
|
|
138
175
|
for (const [lang, source] of Object.entries(sources)) {
|
|
139
176
|
if (typeof source === "function")
|
|
@@ -141,6 +178,10 @@ const createLayer = (core, sources, parent) => {
|
|
|
141
178
|
else if (source)
|
|
142
179
|
layer.dicts[lang] = source;
|
|
143
180
|
}
|
|
181
|
+
for (const [lang, dict] of Object.entries(layer.dicts)) {
|
|
182
|
+
if (lang !== core.defaultLanguage && dict)
|
|
183
|
+
assertPlaceholdersKept((key) => layerLookup(layer, core.defaultLanguage, key), lang, dict, label);
|
|
184
|
+
}
|
|
144
185
|
if (layer.loaders.size > 0)
|
|
145
186
|
core.lazyLayers.add(layer);
|
|
146
187
|
return layer;
|
|
@@ -172,6 +213,7 @@ const startLayerLoad = (core, layer, lang, loader) => {
|
|
|
172
213
|
const dict = dictionaryFromLoaded(loaded, lang);
|
|
173
214
|
if (layer.parent)
|
|
174
215
|
assertNoRedeclaredKeys(layer.parent, core.defaultLanguage, dict, `the "${lang}" loader`);
|
|
216
|
+
assertPlaceholdersKept((key) => layerLookup(layer, core.defaultLanguage, key), lang, dict, `the "${lang}" loader`);
|
|
175
217
|
// Translations registered while the loader ran override what it brought.
|
|
176
218
|
layer.dicts[lang] = { ...dict, ...layer.dicts[lang] };
|
|
177
219
|
})
|
|
@@ -254,11 +296,11 @@ const buildInstance = (core, layer) => {
|
|
|
254
296
|
},
|
|
255
297
|
extend(dict) {
|
|
256
298
|
validateExtension(dict, core.languageList, "extend() dictionary");
|
|
257
|
-
return buildInstance(core, createLayer(core, dict, layer));
|
|
299
|
+
return buildInstance(core, createLayer(core, dict, layer, "extend() dictionary"));
|
|
258
300
|
},
|
|
259
301
|
extendPartial(dict) {
|
|
260
302
|
validateExtension(dict, core.enforced, "extendPartial() dictionary");
|
|
261
|
-
return buildInstance(core, createLayer(core, dict, layer));
|
|
303
|
+
return buildInstance(core, createLayer(core, dict, layer, "extendPartial() dictionary"));
|
|
262
304
|
},
|
|
263
305
|
loadLanguage(code) {
|
|
264
306
|
const lang = code ?? core.state.resolve();
|
|
@@ -269,6 +311,9 @@ const buildInstance = (core, layer) => {
|
|
|
269
311
|
registerDictionary(code, dict) {
|
|
270
312
|
if (!core.isCode(code))
|
|
271
313
|
throw new Error(`LambderI18n: unsupported language code "${code}".`);
|
|
314
|
+
if (code !== core.defaultLanguage) {
|
|
315
|
+
assertPlaceholdersKept((key) => layerLookup(layer, core.defaultLanguage, key), code, dict, `registerDictionary("${code}")`);
|
|
316
|
+
}
|
|
272
317
|
layer.dicts[code] = { ...layer.dicts[code], ...dict };
|
|
273
318
|
core.state.emitChange();
|
|
274
319
|
},
|
|
@@ -350,5 +395,5 @@ export const createLambderI18n = (config) => {
|
|
|
350
395
|
isCode,
|
|
351
396
|
lazyLayers: new Set(),
|
|
352
397
|
};
|
|
353
|
-
return buildInstance(core, createLayer(core, config.base, null));
|
|
398
|
+
return buildInstance(core, createLayer(core, config.base, null, "base dictionary"));
|
|
354
399
|
};
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* dependency-free, so the mock runtime and the browser entry can resolve it.
|
|
10
10
|
*/
|
|
11
11
|
/**
|
|
12
|
-
* The fixed windows a policy may cap, smallest first (the
|
|
13
|
-
* with their length. The policy type derives from this table, so the two can
|
|
12
|
+
* The fixed windows a policy may cap, smallest first (the order a refusal
|
|
13
|
+
* names the smallest window by), with their length. The policy type derives from this table, so the two can
|
|
14
14
|
* never drift.
|
|
15
15
|
*/
|
|
16
16
|
export declare const RATE_LIMIT_WINDOWS: readonly [{
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* dependency-free, so the mock runtime and the browser entry can resolve it.
|
|
10
10
|
*/
|
|
11
11
|
/**
|
|
12
|
-
* The fixed windows a policy may cap, smallest first (the
|
|
13
|
-
* with their length. The policy type derives from this table, so the two can
|
|
12
|
+
* The fixed windows a policy may cap, smallest first (the order a refusal
|
|
13
|
+
* names the smallest window by), with their length. The policy type derives from this table, so the two can
|
|
14
14
|
* never drift.
|
|
15
15
|
*/
|
|
16
16
|
export const RATE_LIMIT_WINDOWS = [
|
|
@@ -8,6 +8,7 @@ import type { LambderCompressedGzipPayload, LambderCompressedBrotliPayload } fro
|
|
|
8
8
|
*/
|
|
9
9
|
export type LambderApiTransportRequest = {
|
|
10
10
|
apiPath: string;
|
|
11
|
+
/** The endpoint called, `group.action`: the call goes to `{apiPath}/{group}/{action}` (apiCallPath). */
|
|
11
12
|
apiName: string;
|
|
12
13
|
version?: string;
|
|
13
14
|
/** The caller's signature for this endpoint, out of its LambderApiSignatureMap; absent when it carries no map. */
|
|
@@ -90,7 +91,8 @@ export declare const isLambderTransportFailure: (err: unknown) => err is Lambder
|
|
|
90
91
|
export type LambderApiTransport = (request: LambderApiTransportRequest) => Promise<LambderApiHttpAnswer>;
|
|
91
92
|
/**
|
|
92
93
|
* The fields of the request envelope, in wire order: the one statement of
|
|
93
|
-
* what a call sends, for every sender.
|
|
94
|
+
* what a call sends, for every sender. The endpoint is not among them: it is
|
|
95
|
+
* the path the envelope is posted to (apiCallPath).
|
|
94
96
|
*
|
|
95
97
|
* Two senders write it. A transport hands the payload over as a value
|
|
96
98
|
* (buildTransportEnvelope, below); LambderInvokeCaller has already serialized
|
|
@@ -101,7 +103,6 @@ export type LambderApiTransport = (request: LambderApiTransportRequest) => Promi
|
|
|
101
103
|
* the wire would catch.
|
|
102
104
|
*/
|
|
103
105
|
export declare const buildEnvelopeFields: (fields: {
|
|
104
|
-
apiName: string;
|
|
105
106
|
version?: string;
|
|
106
107
|
signature?: string;
|
|
107
108
|
/** The CSRF token, as the envelope names it. */
|
|
@@ -16,7 +16,8 @@ export class LambderTransportFailure extends Error {
|
|
|
16
16
|
export const isLambderTransportFailure = (err) => err instanceof Error && err.isLambderTransportFailure === true;
|
|
17
17
|
/**
|
|
18
18
|
* The fields of the request envelope, in wire order: the one statement of
|
|
19
|
-
* what a call sends, for every sender.
|
|
19
|
+
* what a call sends, for every sender. The endpoint is not among them: it is
|
|
20
|
+
* the path the envelope is posted to (apiCallPath).
|
|
20
21
|
*
|
|
21
22
|
* Two senders write it. A transport hands the payload over as a value
|
|
22
23
|
* (buildTransportEnvelope, below); LambderInvokeCaller has already serialized
|
|
@@ -27,7 +28,6 @@ export const isLambderTransportFailure = (err) => err instanceof Error && err.is
|
|
|
27
28
|
* the wire would catch.
|
|
28
29
|
*/
|
|
29
30
|
export const buildEnvelopeFields = (fields) => ({
|
|
30
|
-
apiName: fields.apiName,
|
|
31
31
|
version: fields.version,
|
|
32
32
|
...(fields.signature !== undefined ? { signature: fields.signature } : {}),
|
|
33
33
|
token: fields.token,
|
|
@@ -42,7 +42,6 @@ export const buildEnvelopeFields = (fields) => ({
|
|
|
42
42
|
* runtime read one shape.
|
|
43
43
|
*/
|
|
44
44
|
export const buildTransportEnvelope = (request) => buildEnvelopeFields({
|
|
45
|
-
apiName: request.apiName,
|
|
46
45
|
version: request.version,
|
|
47
46
|
signature: request.signature,
|
|
48
47
|
token: request.token,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { DEFAULT_SESSION_CSRF_COOKIE_KEY } from "../wire/LambderSessionCookieNames.js";
|
|
2
2
|
import { resolveApiPathTarget } from "./LambderApiTransport.js";
|
|
3
|
+
import { apiCallPath } from "../wire/LambderApiNames.js";
|
|
3
4
|
/**
|
|
4
5
|
* Makes any transport carry a cookie jar the way a browser carries its
|
|
5
6
|
* cookies: the jar's cookies ride on every request, the answer's Set-Cookie
|
|
@@ -36,7 +37,7 @@ export const lambderCookieJarTransport = (inner, options) => {
|
|
|
36
37
|
// absolute cross-origin apiPath. siteHost is "" outside a browser, and
|
|
37
38
|
// an empty host would scope every cookie to nothing while claiming to
|
|
38
39
|
// scope it.
|
|
39
|
-
const target = resolveApiPathTarget(request.apiPath);
|
|
40
|
+
const target = resolveApiPathTarget(apiCallPath(request.apiPath, request.apiName));
|
|
40
41
|
const cookieScope = {
|
|
41
42
|
host: target.host ?? options.host ?? (request.siteHost || undefined),
|
|
42
43
|
path: target.path,
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
2
|
+
/** What any layer may ask of the invocation it runs under. */
|
|
3
|
+
export type LambderInvocation = {
|
|
4
|
+
/** The invocation's own request id (the Lambda context's awsRequestId); null when the context has none. */
|
|
5
|
+
readonly requestId: string | null;
|
|
6
|
+
};
|
|
7
|
+
/** The async context the server's handler opens once per invocation. */
|
|
8
|
+
export declare const invocationScope: AsyncLocalStorage<LambderInvocation>;
|
|
9
|
+
/** The request id of the invocation the calling code runs under, or null outside one (a script, a test calling a library directly). */
|
|
10
|
+
export declare const currentRequestId: () => string | null;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
2
|
+
/** The async context the server's handler opens once per invocation. */
|
|
3
|
+
export const invocationScope = new AsyncLocalStorage();
|
|
4
|
+
/** The request id of the invocation the calling code runs under, or null outside one (a script, a test calling a library directly). */
|
|
5
|
+
export const currentRequestId = () => invocationScope.getStore()?.requestId ?? null;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One map of named declarations, or a list of them. An app made of parts,
|
|
3
|
+
* each declaring its own guards, rate-limit policies or refusal codes beside
|
|
4
|
+
* its APIs, hands the list over, and every name in it is declared. A name two
|
|
5
|
+
* maps declare is refused rather than one quietly replacing the other.
|
|
6
|
+
*/
|
|
7
|
+
export type LambderNamedMapsOption<TMap> = TMap | readonly TMap[];
|
|
8
|
+
/** The one map a named-maps option declares: a list's members intersected, a lone map as it is. */
|
|
9
|
+
export type LambderMergedNamedMaps<T> = T extends readonly [] ? {} : T extends readonly [infer TFirst, ...infer TRest] ? TFirst & LambderMergedNamedMaps<TRest> : T extends readonly (infer TMember)[] ? TMember : T;
|
|
10
|
+
/** The names a list declares in more than one of its maps: never for a lone map, or for a list whose members are not known one by one. */
|
|
11
|
+
type LambderRepeatedNames<T, TSeen extends PropertyKey = never> = T extends readonly [infer TFirst, ...infer TRest] ? (keyof TFirst & TSeen) | LambderRepeatedNames<TRest, TSeen | keyof TFirst> : never;
|
|
12
|
+
/** Intersected onto a list: a name declared in two of its maps is a compile error, and the property name is the message. */
|
|
13
|
+
export type LambderNoRepeatedNames<T> = [LambderRepeatedNames<T>] extends [never] ? unknown : {
|
|
14
|
+
readonly "lambder: a name is declared in more than one of these maps. Declare each name once.": LambderRepeatedNames<T>;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* A named-maps option as one map: a lone map as it is, a list merged in
|
|
18
|
+
* order. A name declared in two maps of the list is refused; `what` names a
|
|
19
|
+
* declaration and `where` the call that took the list, for that error.
|
|
20
|
+
*/
|
|
21
|
+
export declare const mergeNamedMaps: <TMap extends Record<string, unknown>>(option: LambderNamedMapsOption<TMap>, what: "guard" | "rate-limit policy" | "refusal code", where?: string) => TMap;
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A named-maps option as one map: a lone map as it is, a list merged in
|
|
3
|
+
* order. A name declared in two maps of the list is refused; `what` names a
|
|
4
|
+
* declaration and `where` the call that took the list, for that error.
|
|
5
|
+
*/
|
|
6
|
+
export const mergeNamedMaps = (option, what, where = "create()") => {
|
|
7
|
+
if (!Array.isArray(option))
|
|
8
|
+
return option;
|
|
9
|
+
const merged = {};
|
|
10
|
+
for (const map of option) {
|
|
11
|
+
for (const [name, declaration] of Object.entries(map)) {
|
|
12
|
+
if (Object.prototype.hasOwnProperty.call(merged, name)) {
|
|
13
|
+
throw new Error(`Lambder: the ${what} "${name}" is declared in two of the maps given to ${where}. Declare each name once.`);
|
|
14
|
+
}
|
|
15
|
+
merged[name] = declaration;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return merged;
|
|
19
|
+
};
|
|
@@ -35,7 +35,7 @@ export type LambderReadonlyDeep<T, TDepth extends unknown[] = []> = TDepth["leng
|
|
|
35
35
|
* name, of "this one required and the rest optional".
|
|
36
36
|
*
|
|
37
37
|
* An all-optional map is inhabited by `{}`, which would let `guards: {}`
|
|
38
|
-
* satisfy
|
|
38
|
+
* satisfy requireApiGuards at the type level
|
|
39
39
|
* while declaring no guard: the option is present, so the required-field
|
|
40
40
|
* check passes, and it normalizes to zero entries, so nothing runs. Requiring
|
|
41
41
|
* the chosen key also rejects `{ theGuard: undefined }`, which an optional
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Lambder API Contract System
|
|
3
3
|
*
|
|
4
|
-
* Contracts are
|
|
4
|
+
* Contracts are the endpoint declarations an instance registered, read with typeof lambder.ApiContract
|
|
5
5
|
*/
|
|
6
6
|
import type { LambderCrashDetail } from "./LambderCrashDetail.js";
|
|
7
7
|
import type { LambderUncheckedRefusalMessage, LambderPlainRefusalMessage, LambderRefusalMessage } from "./LambderApiRefusal.js";
|
|
8
8
|
import type { LambderApiIdempotencyOption, LambderGuardsOptionValue, LambderRateLimitOptionValue } from "./LambderApiOptionValues.js";
|
|
9
|
-
/** Whether an endpoint runs without a session or requires one
|
|
9
|
+
/** Whether an endpoint runs without a session or requires one: "session" exactly when one of its guards needs a session. */
|
|
10
10
|
export type LambderApiMode = "public" | "session";
|
|
11
11
|
/**
|
|
12
12
|
* Base shape for API contracts: what LambderCaller, LambderInvokeCaller and
|
|
@@ -15,7 +15,7 @@ export type LambderApiMode = "public" | "session";
|
|
|
15
15
|
export type LambderApiContractShape = Record<string, {
|
|
16
16
|
input: any;
|
|
17
17
|
output: any;
|
|
18
|
-
/** "public"
|
|
18
|
+
/** "public", or "session" when one of its guards needs a session. */
|
|
19
19
|
mode?: LambderApiMode;
|
|
20
20
|
/** Present when the API declares guardInput-mode guards: guard name -> value the client must send via options.guardInputs. */
|
|
21
21
|
guardInputs?: any;
|
|
@@ -149,7 +149,7 @@ export type LambderJsonOf<T> = unknown extends T ? T : T extends LambderJsonValu
|
|
|
149
149
|
[K in keyof TKeyed as K extends string | number ? (string extends K ? K : number extends K ? K : [LambderJsonOf<TKeyed[K]>] extends [never] ? never : K) : never]: LambderJsonOf<TKeyed[K]>;
|
|
150
150
|
} : never : never;
|
|
151
151
|
/**
|
|
152
|
-
* One contract entry as
|
|
152
|
+
* One contract entry as defineApi records it: the payload types,
|
|
153
153
|
* the mode, and every declarative option exactly as written. Options that
|
|
154
154
|
* were not written are absent rather than undefined, so `keyof` an entry
|
|
155
155
|
* lists only what the endpoint declared.
|
|
@@ -180,20 +180,6 @@ export type LambderContractEntry<In, Out, Mode extends LambderApiMode, GuardInpu
|
|
|
180
180
|
}) & ([Idempotency] extends [never] ? {} : {
|
|
181
181
|
idempotency: Idempotency;
|
|
182
182
|
});
|
|
183
|
-
/**
|
|
184
|
-
* Merges a new entry into the contract during chaining.
|
|
185
|
-
*
|
|
186
|
-
* The contract is therefore an intersection one member deep per endpoint, and
|
|
187
|
-
* every `C[K]` read generically (a typed caller, a mock registry, a test
|
|
188
|
-
* visitor) resolves the property across all of them. That costs nothing
|
|
189
|
-
* worth measuring in a small app and most of a large client's type check,
|
|
190
|
-
* which is what writeApiContract (lambder/build) is for: it writes the
|
|
191
|
-
* contract out as one object type with plain members, for clients to import
|
|
192
|
-
* instead of the server.
|
|
193
|
-
*/
|
|
194
|
-
export type LambderMergeContract<Old, Name extends string, Entry> = Old & {
|
|
195
|
-
[K in Name]: Entry;
|
|
196
|
-
};
|
|
197
183
|
/** Guard names referenced by a guards option, whichever of its three forms is used. */
|
|
198
184
|
export type LambderGuardNamesIn<TOpt> = TOpt extends string ? TOpt : TOpt extends readonly (infer N extends string)[] ? N : TOpt extends object ? keyof TOpt & string : never;
|
|
199
185
|
/** The endpoint's mode; a contract written without one admits either. */
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** The groups a contract's endpoint names hold: the part of each before its dot. */
|
|
2
|
+
export type LambderContractGroupsOf<TContract> = keyof TContract extends infer TName ? (TName extends `${infer TGroup}.${string}` ? TGroup : never) : never;
|
|
3
|
+
/** The endpoint names of one group. */
|
|
4
|
+
export type LambderContractNamesInGroup<TContract, TGroup extends string> = Extract<keyof TContract, `${TGroup}.${string}`> & string;
|
|
5
|
+
/** An endpoint name's action: the part after its dot. */
|
|
6
|
+
export type LambderContractActionOf<TName> = TName extends `${string}.${infer TAction}` ? TAction : never;
|
|
7
|
+
/** A call made through a group: the endpoint's name, and the arguments as the call site passed them. */
|
|
8
|
+
type LambderGroupCallRunner = (apiName: string, args: unknown[]) => unknown;
|
|
9
|
+
/**
|
|
10
|
+
* The caller, answering for each group a contract could name besides its
|
|
11
|
+
* own members. A member the caller has (a method, a field) is always the
|
|
12
|
+
* caller's; a name that could be a group (see isGroupName: an identifier no
|
|
13
|
+
* caller member has) is one; anything else a runtime or a framework asks of
|
|
14
|
+
* an object (then, toJSON, a symbol, `__v_isRef`) is what the caller itself
|
|
15
|
+
* answers, so a caller is never mistaken for a promise, a ref or a
|
|
16
|
+
* serializable record.
|
|
17
|
+
*
|
|
18
|
+
* A caller keeps its state in #private fields, which no property name
|
|
19
|
+
* reaches, so a group may take any name its public members leave free. Such
|
|
20
|
+
* a field is readable only on the caller itself, never through this proxy,
|
|
21
|
+
* so a member is read and a method run on the caller; a method that returns
|
|
22
|
+
* the caller (setTransport) hands back the proxy, groups and all.
|
|
23
|
+
*
|
|
24
|
+
* A group answers the same way one level down: an ordinary object's own
|
|
25
|
+
* members (toString, valueOf, hasOwnProperty) are an ordinary object's, so
|
|
26
|
+
* converting a group to a string or asking it what it holds calls no
|
|
27
|
+
* endpoint; any other name that could be an action (see isActionName) is
|
|
28
|
+
* one. A group is read-only, like the type that describes it.
|
|
29
|
+
*/
|
|
30
|
+
export declare const withApiGroupCalls: <TCaller extends object>(caller: TCaller, call: LambderGroupCallRunner, outcome: LambderGroupCallRunner) => TCaller;
|
|
31
|
+
export {};
|