lambder 7.3.1 → 8.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 +933 -3
- package/README.md +41 -21
- package/dist/api/LambderApiAnswer.d.ts +18 -22
- package/dist/api/LambderApiAnswer.js +6 -7
- package/dist/api/LambderApiCallContext.d.ts +21 -8
- package/dist/api/LambderApiCallContext.js +22 -4
- package/dist/api/LambderApiDefinition.d.ts +4 -3
- package/dist/api/LambderApiEnvelope.d.ts +14 -9
- package/dist/api/LambderApiEnvelope.js +33 -34
- package/dist/api/LambderApiGuards.d.ts +78 -51
- package/dist/api/LambderApiGuards.js +34 -36
- package/dist/api/LambderApiIdempotency.d.ts +68 -62
- package/dist/api/LambderApiIdempotency.js +214 -151
- package/dist/api/LambderApiOutputValidationError.d.ts +32 -0
- package/dist/api/LambderApiOutputValidationError.js +50 -0
- package/dist/api/LambderApiPipeline.d.ts +47 -38
- package/dist/api/LambderApiPipeline.js +122 -63
- package/dist/api/LambderApiRateLimits.d.ts +201 -54
- package/dist/api/LambderApiRateLimits.js +185 -108
- package/dist/api/LambderApiRequest.d.ts +27 -21
- package/dist/api/LambderApiRequest.js +26 -19
- package/dist/api/LambderApiSignature.d.ts +12 -15
- package/dist/api/LambderApiSignature.js +28 -51
- package/dist/api/LambderApiValidationRefusal.d.ts +9 -9
- package/dist/api/LambderApiValidationRefusal.js +10 -10
- package/dist/build/freshProcessVerifier.d.ts +13 -0
- package/dist/build/freshProcessVerifier.js +19 -0
- package/dist/build/writeApiSignatures.d.ts +109 -0
- package/dist/build/writeApiSignatures.js +222 -0
- package/dist/build.d.ts +9 -0
- package/dist/build.js +8 -0
- package/dist/client/LambderCaller.d.ts +13 -44
- package/dist/client/LambderCaller.js +77 -84
- package/dist/client/LambderReloadLoopBreaker.d.ts +56 -26
- package/dist/client/LambderReloadLoopBreaker.js +90 -46
- package/dist/client/lambderFetchTransport.d.ts +4 -1
- package/dist/client/lambderFetchTransport.js +52 -28
- package/dist/client.d.ts +5 -3
- package/dist/client.js +2 -1
- package/dist/core/Lambder.d.ts +140 -75
- package/dist/core/Lambder.js +347 -227
- package/dist/core/LambderContext.d.ts +82 -15
- package/dist/core/LambderContext.js +107 -20
- package/dist/core/LambderCors.d.ts +21 -3
- package/dist/core/LambderCors.js +35 -16
- package/dist/core/LambderCrashHandling.d.ts +40 -0
- package/dist/core/LambderCrashHandling.js +97 -0
- package/dist/core/LambderCreateOptions.d.ts +151 -75
- package/dist/core/LambderCreateOptions.js +16 -23
- package/dist/core/LambderFiles.d.ts +21 -7
- package/dist/core/LambderFiles.js +62 -34
- package/dist/core/LambderIndexHtml.js +12 -11
- package/dist/core/LambderPolicyBuilders.d.ts +17 -5
- package/dist/core/LambderPolicyBuilders.js +17 -5
- package/dist/core/LambderPublicFiles.d.ts +11 -5
- package/dist/core/LambderPublicFiles.js +32 -4
- package/dist/core/LambderRequestPath.d.ts +43 -0
- package/dist/core/LambderRequestPath.js +63 -0
- package/dist/core/LambderResponse.d.ts +26 -5
- package/dist/core/LambderResponse.js +157 -70
- package/dist/core/LambderResponseBuilder.d.ts +49 -4
- package/dist/core/LambderResponseBuilder.js +64 -3
- package/dist/core/LambderRouting.d.ts +2 -3
- package/dist/core/LambderRouting.js +22 -7
- package/dist/core/LambderTemplatingEngine.js +211 -32
- package/dist/index.d.ts +15 -8
- package/dist/index.js +5 -4
- package/dist/invoke/LambderInvokeCaller.d.ts +37 -42
- package/dist/invoke/LambderInvokeCaller.js +76 -66
- package/dist/invoke/LambderInvokeOutcome.d.ts +27 -26
- package/dist/invoke/LambderInvokeOutcome.js +9 -22
- package/dist/invoke/LambderLambdaEvent.d.ts +29 -9
- package/dist/invoke/LambderLambdaEvent.js +40 -22
- package/dist/invoke/lambderHandlerTransport.d.ts +9 -10
- package/dist/invoke/lambderHandlerTransport.js +15 -18
- package/dist/mock/LambderMockApp.d.ts +67 -83
- package/dist/mock/LambderMockApp.js +167 -153
- package/dist/mock/LambderMockBrowserCookies.d.ts +24 -28
- package/dist/mock/LambderMockBrowserCookies.js +24 -28
- package/dist/mock/LambderMockCallRecorder.d.ts +15 -22
- package/dist/mock/LambderMockCallRecorder.js +19 -28
- package/dist/mock/LambderMockCreateOptions.d.ts +42 -24
- package/dist/mock/LambderMockEntryRegistry.d.ts +11 -12
- package/dist/mock/LambderMockEntryRegistry.js +24 -29
- package/dist/mock/LambderMockFailureInjector.d.ts +3 -6
- package/dist/mock/LambderMockFailureInjector.js +3 -6
- package/dist/mock/LambderMockTypes.d.ts +78 -108
- package/dist/mock/lambderMockInvokeTransport.d.ts +11 -13
- package/dist/mock/lambderMockInvokeTransport.js +11 -10
- package/dist/mock/lambderMockMswHandler.d.ts +33 -29
- package/dist/mock/lambderMockMswHandler.js +50 -39
- package/dist/mock.d.ts +1 -1
- package/dist/mock.js +2 -3
- package/dist/session/LambderSessionController.d.ts +108 -89
- package/dist/session/LambderSessionController.js +187 -168
- package/dist/session/LambderSessionCrypto.d.ts +16 -7
- package/dist/session/LambderSessionCrypto.js +26 -12
- package/dist/session/LambderSessionManager.d.ts +124 -46
- package/dist/session/LambderSessionManager.js +262 -137
- package/dist/shared/LambderHtml.d.ts +42 -3
- package/dist/shared/LambderHtml.js +127 -7
- package/dist/shared/LambderHtmlPositions.d.ts +173 -0
- package/dist/shared/LambderHtmlPositions.js +652 -0
- package/dist/shared/LambderI18n.d.ts +10 -11
- package/dist/shared/LambderI18n.js +33 -21
- package/dist/shared/contracts/LambderCache.d.ts +66 -0
- package/dist/shared/contracts/LambderCache.js +11 -0
- package/dist/shared/contracts/LambderFileSource.d.ts +6 -6
- package/dist/shared/contracts/LambderFileSource.js +5 -8
- package/dist/shared/contracts/LambderIdempotencyStore.d.ts +51 -22
- package/dist/shared/contracts/LambderIdempotencyStore.js +4 -5
- package/dist/shared/contracts/LambderRateLimiter.d.ts +27 -15
- package/dist/shared/contracts/LambderRateLimiter.js +4 -5
- package/dist/shared/contracts/LambderSessionStore.d.ts +65 -26
- package/dist/shared/contracts/LambderSessionStore.js +5 -6
- package/dist/shared/transport/LambderApiTransport.d.ts +27 -27
- package/dist/shared/transport/LambderApiTransport.js +7 -7
- package/dist/shared/transport/LambderCookieJar.d.ts +28 -35
- package/dist/shared/transport/LambderCookieJar.js +54 -66
- package/dist/shared/transport/lambderCookieJarTransport.d.ts +11 -13
- package/dist/shared/transport/lambderCookieJarTransport.js +24 -23
- package/dist/shared/util/LambderCallAbort.d.ts +5 -5
- package/dist/shared/util/LambderCallAbort.js +5 -5
- package/dist/shared/util/LambderClientIp.d.ts +27 -11
- package/dist/shared/util/LambderClientIp.js +96 -13
- package/dist/shared/util/LambderExpiringMap.d.ts +35 -49
- package/dist/shared/util/LambderExpiringMap.js +41 -57
- package/dist/shared/util/LambderNodeModules.js +6 -7
- package/dist/shared/util/LambderOptionChecks.d.ts +4 -4
- package/dist/shared/util/LambderOptionChecks.js +4 -4
- package/dist/shared/util/LambderResponseBrand.d.ts +5 -5
- package/dist/shared/util/LambderResponseBrand.js +5 -5
- package/dist/shared/util/LambderTypeUtilities.d.ts +7 -8
- package/dist/shared/util/LambderTypeUtilities.js +3 -3
- package/dist/shared/util/boundKeyField.d.ts +20 -0
- package/dist/shared/util/boundKeyField.js +34 -0
- package/dist/shared/util/canonicalJson.d.ts +11 -0
- package/dist/shared/util/canonicalJson.js +28 -0
- package/dist/shared/util/joinKeyFields.d.ts +20 -0
- package/dist/shared/util/joinKeyFields.js +22 -0
- package/dist/shared/wire/LambderAnswerHeaders.d.ts +12 -16
- package/dist/shared/wire/LambderAnswerHeaders.js +12 -16
- package/dist/shared/wire/LambderApiContract.d.ts +107 -32
- package/dist/shared/wire/LambderApiOutcome.d.ts +43 -31
- package/dist/shared/wire/LambderApiOutcome.js +48 -23
- package/dist/shared/wire/LambderApiRefusal.d.ts +39 -27
- package/dist/shared/wire/LambderApiRefusal.js +36 -7
- package/dist/shared/wire/LambderApiSignature.d.ts +18 -22
- package/dist/shared/wire/LambderApiSignature.js +16 -19
- package/dist/shared/wire/LambderCallOptions.d.ts +38 -47
- package/dist/shared/wire/LambderCallOptions.js +9 -11
- package/dist/shared/wire/LambderCompressionCodec.d.ts +29 -34
- package/dist/shared/wire/LambderCompressionCodec.js +31 -36
- package/dist/shared/wire/LambderCompressionOption.d.ts +9 -9
- package/dist/shared/wire/LambderCompressionOption.js +9 -9
- package/dist/shared/wire/LambderCrashDetail.d.ts +12 -15
- package/dist/shared/wire/LambderCrashDetail.js +12 -15
- package/dist/shared/wire/LambderDefaultApiPath.d.ts +6 -0
- package/dist/shared/wire/LambderDefaultApiPath.js +6 -0
- package/dist/shared/wire/LambderHttpStatus.d.ts +6 -7
- package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +89 -0
- package/dist/shared/wire/LambderIdempotencyKeyScope.js +146 -0
- package/dist/shared/wire/LambderInvokeApiId.d.ts +27 -0
- package/dist/shared/wire/LambderInvokeApiId.js +27 -0
- package/dist/shared/wire/LambderOutcomeAssertions.d.ts +6 -7
- package/dist/shared/wire/LambderOutcomeAssertions.js +6 -7
- package/dist/shared/wire/LambderRequestPayload.d.ts +18 -20
- package/dist/shared/wire/LambderRequestPayload.js +4 -6
- package/dist/stores/LambderCacheFiller.d.ts +48 -0
- package/dist/stores/LambderCacheFiller.js +119 -0
- package/dist/stores/LambderCacheKeys.d.ts +26 -0
- package/dist/stores/LambderCacheKeys.js +54 -0
- package/dist/stores/LambderCacheValues.d.ts +45 -0
- package/dist/stores/LambderCacheValues.js +74 -0
- package/dist/stores/LambderDdbCache.d.ts +121 -56
- package/dist/stores/LambderDdbCache.js +528 -225
- package/dist/stores/LambderDdbIdempotencyStore.d.ts +33 -22
- package/dist/stores/LambderDdbIdempotencyStore.js +75 -50
- package/dist/stores/LambderDdbRateLimiter.d.ts +76 -20
- package/dist/stores/LambderDdbRateLimiter.js +151 -39
- package/dist/stores/LambderDdbSdk.d.ts +43 -31
- package/dist/stores/LambderDdbSdk.js +79 -33
- package/dist/stores/LambderDdbSessionStore.d.ts +27 -14
- package/dist/stores/LambderDdbSessionStore.js +119 -47
- package/dist/stores/LambderHttpFileSource.d.ts +15 -6
- package/dist/stores/LambderHttpFileSource.js +15 -13
- package/dist/stores/LambderMemoryCache.d.ts +49 -0
- package/dist/stores/LambderMemoryCache.js +113 -0
- package/dist/stores/LambderMemoryIdempotencyStore.d.ts +13 -12
- package/dist/stores/LambderMemoryIdempotencyStore.js +31 -30
- package/dist/stores/LambderMemoryRateLimiter.d.ts +8 -9
- package/dist/stores/LambderMemoryRateLimiter.js +14 -13
- package/dist/stores/LambderMemorySessionStore.d.ts +14 -11
- package/dist/stores/LambderMemorySessionStore.js +38 -19
- package/dist/stores/LambderS3FileSource.d.ts +21 -6
- package/dist/stores/LambderS3FileSource.js +12 -7
- package/dist/testing/LambderTestApp.d.ts +21 -23
- package/dist/testing/LambderTestApp.js +22 -24
- package/dist/testing/LambderTestVisitor.d.ts +10 -12
- package/dist/testing/LambderTestVisitor.js +15 -15
- package/dist/testing.d.ts +1 -0
- package/dist/testing.js +1 -0
- package/package.json +12 -3
- package/dist/api/LambderApiPolicyEngine.d.ts +0 -47
- package/dist/api/LambderApiPolicyEngine.js +0 -85
- package/dist/shared/util/LambderKeyFields.d.ts +0 -32
- package/dist/shared/util/LambderKeyFields.js +0 -34
|
@@ -5,47 +5,65 @@
|
|
|
5
5
|
* over a direct Lambda invoke) receive the same envelope and must read it
|
|
6
6
|
* the same way: which status is a crash, which is a rejected input, in what
|
|
7
7
|
* order the envelope flags are honoured, what a non-envelope body means.
|
|
8
|
-
* Both hand their answer to resolveApiOutcome and act on the result;
|
|
9
|
-
* side effects
|
|
10
|
-
*
|
|
11
|
-
* entry resolves it.
|
|
8
|
+
* Both hand their answer to resolveApiOutcome and act on the result; their
|
|
9
|
+
* side effects (handlers, cookie clearing, error reporting) stay with them.
|
|
10
|
+
* Pure and dependency-free, so the browser entry can include it.
|
|
12
11
|
*/
|
|
12
|
+
import { refusalMessageOf } from "./LambderApiRefusal.js";
|
|
13
|
+
/**
|
|
14
|
+
* Whether a parsed body is Lambder's envelope, which always carries
|
|
15
|
+
* apiVersion (null when the server set none). An object without it is
|
|
16
|
+
* somebody else's answer: API Gateway's own errors ({"message": ...} on a
|
|
17
|
+
* 413, a throttle, a WAF or missing-route 403, an authorizer 401, a 502 from
|
|
18
|
+
* a crashed function), a proxy's, a load balancer's.
|
|
19
|
+
*/
|
|
20
|
+
const isApiEnvelope = (value) => value !== null && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, "apiVersion");
|
|
13
21
|
/**
|
|
14
22
|
* Reads one HTTP answer into an outcome. A 5xx is a server failure that keeps
|
|
15
23
|
* the envelope when the server sent one (Lambder's own 500 body carries
|
|
16
24
|
* errorMessage, and a global error handler may add crash and logList); a
|
|
17
25
|
* 422 is a validation failure only with Lambder's validation body; anything
|
|
18
26
|
* else must be a JSON envelope, whose flags are honoured in a fixed order.
|
|
27
|
+
* A failure read off any answer but a 422 carries the answer's Retry-After
|
|
28
|
+
* as retryAfterSeconds.
|
|
19
29
|
*/
|
|
20
30
|
export const resolveApiOutcome = async (answer) => {
|
|
21
31
|
const status = answer.status;
|
|
32
|
+
const errorMessageOf = (envelope) => envelope?.errorMessage !== undefined ? { errorMessage: refusalMessageOf(envelope.errorMessage) } : {};
|
|
33
|
+
// Retry-After (delta-seconds) rides every refusal that knows its reset
|
|
34
|
+
// time, e.g. a rate limit, and a 503 that says when to come back; absent
|
|
35
|
+
// or unreadable is undefined.
|
|
36
|
+
const retryAfterValue = Number(answer.header("retry-after") ?? NaN);
|
|
37
|
+
const retryAfter = Number.isFinite(retryAfterValue) && retryAfterValue >= 0 ? { retryAfterSeconds: retryAfterValue } : {};
|
|
22
38
|
if (status >= 500) {
|
|
23
39
|
// Lambder's own 500 fallback is a JSON envelope, but custom error
|
|
24
|
-
// handlers may answer text
|
|
40
|
+
// handlers may answer text or HTML, and a gateway in front answers
|
|
41
|
+
// JSON of its own: parse defensively, and keep only the envelope, so
|
|
42
|
+
// a foreign body's fields never read as the app's message or crash.
|
|
25
43
|
let envelope;
|
|
26
44
|
try {
|
|
27
45
|
const bodyText = await answer.text();
|
|
28
46
|
try {
|
|
29
47
|
const parsed = JSON.parse(bodyText);
|
|
30
|
-
if (parsed
|
|
48
|
+
if (isApiEnvelope(parsed))
|
|
31
49
|
envelope = parsed;
|
|
32
50
|
}
|
|
33
|
-
catch { /* not
|
|
51
|
+
catch { /* not JSON */ }
|
|
34
52
|
}
|
|
35
53
|
catch { /* body unavailable */ }
|
|
36
54
|
return {
|
|
37
55
|
ok: false, reason: 'server', status,
|
|
38
|
-
|
|
56
|
+
...errorMessageOf(envelope),
|
|
39
57
|
logList: envelope?.logList,
|
|
40
58
|
...(envelope ? { response: envelope } : {}),
|
|
41
59
|
error: new Error("Request failed: " + status + " - " + (answer.statusText ?? "")),
|
|
60
|
+
...retryAfter,
|
|
42
61
|
};
|
|
43
62
|
}
|
|
44
63
|
if (status === 422) {
|
|
45
64
|
// A 422 without Lambder's validation body (e.g. a proxy's error page)
|
|
46
|
-
// is a server failure, not a validation result.
|
|
47
|
-
//
|
|
48
|
-
// answer does, so it is read here rather than left on the wire.
|
|
65
|
+
// is a server failure, not a validation result. The validation body
|
|
66
|
+
// carries the call's logList like every other answer, so it is read.
|
|
49
67
|
let body;
|
|
50
68
|
try {
|
|
51
69
|
body = await answer.json();
|
|
@@ -57,10 +75,6 @@ export const resolveApiOutcome = async (answer) => {
|
|
|
57
75
|
}
|
|
58
76
|
return { ok: false, reason: 'validation', status, zodError, logList: body?.logList };
|
|
59
77
|
}
|
|
60
|
-
// Retry-After (delta-seconds) rides every refusal that knows its reset
|
|
61
|
-
// time, e.g. a rate limit; absent or unreadable is undefined.
|
|
62
|
-
const retryAfterValue = Number(answer.header("retry-after") ?? NaN);
|
|
63
|
-
const retryAfter = Number.isFinite(retryAfterValue) && retryAfterValue >= 0 ? { retryAfterSeconds: retryAfterValue } : {};
|
|
64
78
|
let data;
|
|
65
79
|
try {
|
|
66
80
|
data = await answer.json();
|
|
@@ -69,18 +83,29 @@ export const resolveApiOutcome = async (answer) => {
|
|
|
69
83
|
}
|
|
70
84
|
catch (err) {
|
|
71
85
|
// A non-envelope body (e.g. an HTML error page) is a server failure.
|
|
72
|
-
return { ok: false, reason: 'server', status, error: new Error("Request failed: response is not a valid API envelope (status " + status + ")", { cause: err }) };
|
|
86
|
+
return { ok: false, reason: 'server', status, error: new Error("Request failed: response is not a valid API envelope (status " + status + ")", { cause: err }), ...retryAfter };
|
|
87
|
+
}
|
|
88
|
+
// Read as envelopes, a gateway's own JSON errors would resolve as
|
|
89
|
+
// successes with no payload, so a refused save would look saved.
|
|
90
|
+
if (!isApiEnvelope(data)) {
|
|
91
|
+
const gatewayMessage = typeof data.message === "string" ? `: ${data.message}` : "";
|
|
92
|
+
return { ok: false, reason: 'server', status, error: new Error(`Request failed: ${status} - the answer is not a Lambder envelope${gatewayMessage}`), ...retryAfter };
|
|
73
93
|
}
|
|
74
94
|
if (data.versionExpired)
|
|
75
|
-
return { ok: false, reason: 'versionExpired', status,
|
|
95
|
+
return { ok: false, reason: 'versionExpired', status, ...errorMessageOf(data), response: data, logList: data.logList, ...retryAfter };
|
|
76
96
|
if (data.sessionExpired)
|
|
77
|
-
return { ok: false, reason: 'sessionExpired', status,
|
|
97
|
+
return { ok: false, reason: 'sessionExpired', status, ...errorMessageOf(data), response: data, logList: data.logList, ...retryAfter };
|
|
78
98
|
if (data.notAuthorized)
|
|
79
|
-
return { ok: false, reason: 'notAuthorized', status,
|
|
80
|
-
// Presence, not truthiness: the writer keeps an errorMessage an app
|
|
81
|
-
//
|
|
82
|
-
// refusal
|
|
99
|
+
return { ok: false, reason: 'notAuthorized', status, ...errorMessageOf(data), response: data, logList: data.logList, ...retryAfter };
|
|
100
|
+
// Presence, not truthiness: the writer keeps an errorMessage an app set
|
|
101
|
+
// to the empty string, and a refusal that says nothing is still a
|
|
102
|
+
// refusal, not a success.
|
|
83
103
|
if (data.errorMessage !== undefined)
|
|
84
|
-
return { ok: false, reason: 'errorMessage', status, errorMessage: data.errorMessage, response: data, logList: data.logList, ...retryAfter };
|
|
104
|
+
return { ok: false, reason: 'errorMessage', status, errorMessage: refusalMessageOf(data.errorMessage), response: data, logList: data.logList, ...retryAfter };
|
|
105
|
+
// An envelope that says nothing is wrong is still not a success when the
|
|
106
|
+
// status says otherwise.
|
|
107
|
+
if (status < 200 || status >= 300) {
|
|
108
|
+
return { ok: false, reason: 'server', status, response: data, logList: data.logList, error: new Error(`Request failed: ${status} - ${answer.statusText ?? ""}`), ...retryAfter };
|
|
109
|
+
}
|
|
85
110
|
return { ok: true, payload: data.payload, response: data, logList: data.logList };
|
|
86
111
|
};
|
|
@@ -3,9 +3,9 @@ export type LambderApiRefusalOptions = {
|
|
|
3
3
|
/**
|
|
4
4
|
* User-facing failure detail placed on the API envelope's `errorMessage`
|
|
5
5
|
* field: a refusal message (`{ type: "warning", content: "..." }`, with
|
|
6
|
-
* an app's own `code`) or a plain string
|
|
7
|
-
*
|
|
8
|
-
* visible to the client.
|
|
6
|
+
* an app's own `code`) or a plain string, which becomes an "error"
|
|
7
|
+
* message with that content. Defaults to the error message, so a bare
|
|
8
|
+
* `throw new LambderApiRefusal("...")` is still visible to the client.
|
|
9
9
|
*/
|
|
10
10
|
errorMessage?: LambderAppRefusalMessage | string;
|
|
11
11
|
/** Sets the envelope's `notAuthorized` flag (routed to the caller's notAuthorizedHandler). */
|
|
@@ -26,18 +26,18 @@ export type LambderApiRefusalOptions = {
|
|
|
26
26
|
/**
|
|
27
27
|
* A typed refusal: "this request is denied/invalid" as opposed to "the server
|
|
28
28
|
* crashed". Throw it from anywhere in an API call's call stack (handlers,
|
|
29
|
-
* hooks, or nested helpers
|
|
30
|
-
*
|
|
29
|
+
* hooks, or nested helpers with no access to the per-request resolver) and
|
|
30
|
+
* the render pipeline maps it onto the structured API envelope
|
|
31
31
|
* (`res.api(null, { errorMessage, notAuthorized, sessionExpired })`) instead
|
|
32
|
-
* of routing it through setGlobalErrorHandler
|
|
33
|
-
*
|
|
32
|
+
* of routing it through setGlobalErrorHandler, so refusals never reach crash
|
|
33
|
+
* logging and clients receive a parseable response.
|
|
34
34
|
*
|
|
35
35
|
* Thrown outside an API call (e.g. in a route handler) it behaves like any
|
|
36
36
|
* other error: global error handler, then the default 500.
|
|
37
37
|
*
|
|
38
38
|
* Isomorphic and dependency-free, so shared code (validators, permission
|
|
39
|
-
* checks)
|
|
40
|
-
* browser
|
|
39
|
+
* checks) used by both server and browser builds may throw it; in the
|
|
40
|
+
* browser it is just an Error.
|
|
41
41
|
*/
|
|
42
42
|
export declare class LambderApiRefusal extends Error {
|
|
43
43
|
/**
|
|
@@ -46,7 +46,8 @@ export declare class LambderApiRefusal extends Error {
|
|
|
46
46
|
* across them while this marker does not. The pipeline checks the brand.
|
|
47
47
|
*/
|
|
48
48
|
readonly isLambderApiRefusal = true;
|
|
49
|
-
|
|
49
|
+
/** What the envelope's errorMessage carries: always a message object, a plain string given to the options made into one. */
|
|
50
|
+
readonly errorMessage: LambderAppRefusalMessage;
|
|
50
51
|
readonly notAuthorized?: boolean;
|
|
51
52
|
readonly sessionExpired?: boolean;
|
|
52
53
|
readonly statusCode?: LambderHttpStatusCode;
|
|
@@ -59,11 +60,11 @@ export declare const isLambderApiRefusal: (err: unknown) => err is LambderApiRef
|
|
|
59
60
|
* The standard shape refusals carry on the envelope's errorMessage field.
|
|
60
61
|
* `code` is the refusal's machine-readable identity: clients branch and
|
|
61
62
|
* translate on it and never string-match `content`, which stays the
|
|
62
|
-
* human-readable fallback for codes a client does not know yet.
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* as-is, typed as LambderAppRefusalMessage
|
|
66
|
-
*
|
|
63
|
+
* human-readable fallback for codes a client does not know yet. The
|
|
64
|
+
* framework's own refusals carry a LambderRefusalCode; apps put their own
|
|
65
|
+
* typed vocabulary in `code`. The caller's errorMessageHandler receives the
|
|
66
|
+
* object as-is, typed as LambderAppRefusalMessage; a server that wrote a
|
|
67
|
+
* plain string reaches it as `{ type: "error", content }` (refusalMessageOf).
|
|
67
68
|
*/
|
|
68
69
|
export type LambderRefusalMessage<TAppCode extends string = never> = {
|
|
69
70
|
type: "warning" | "error" | "info";
|
|
@@ -71,14 +72,13 @@ export type LambderRefusalMessage<TAppCode extends string = never> = {
|
|
|
71
72
|
* Machine-readable identity of the refusal: a LambderRefusalCode, plus
|
|
72
73
|
* whatever vocabulary the reader names in TAppCode.
|
|
73
74
|
*
|
|
74
|
-
* Parameterized rather than widened with `string & {}
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* where any code is welcome.
|
|
75
|
+
* Parameterized rather than widened with `string & {}`: a union with
|
|
76
|
+
* `string` in it does not narrow, so a `switch(message.code)` could not
|
|
77
|
+
* end in a `default: never` exhaustiveness check, which is what the codes
|
|
78
|
+
* exist for. A client that reads its own vocabulary declares it
|
|
79
|
+
* (`LambderRefusalMessage<"app/not-verified" | ...>`) and gets a checked
|
|
80
|
+
* switch; a value an app WRITES takes LambderAppRefusalMessage, where any
|
|
81
|
+
* code is welcome.
|
|
82
82
|
*/
|
|
83
83
|
code?: LambderRefusalCode | TAppCode;
|
|
84
84
|
title?: string;
|
|
@@ -86,12 +86,22 @@ export type LambderRefusalMessage<TAppCode extends string = never> = {
|
|
|
86
86
|
};
|
|
87
87
|
/**
|
|
88
88
|
* The refusal shape an app authors: any code, with the framework's own still
|
|
89
|
-
* autocompleting.
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
89
|
+
* autocompleting. Every option that takes a message from an app is typed as
|
|
90
|
+
* this (a rate-limit policy's errorMessage, the mock's failure injection);
|
|
91
|
+
* LambderRefusalMessage itself defaults to the framework's codes alone, so a
|
|
92
|
+
* reader's switch over it is exhaustive.
|
|
93
93
|
*/
|
|
94
94
|
export type LambderAppRefusalMessage = LambderRefusalMessage<string & {}>;
|
|
95
|
+
/**
|
|
96
|
+
* An errorMessage as the one shape a reader handles: a plain string becomes
|
|
97
|
+
* an "error" message with that content, and a value that is not a message
|
|
98
|
+
* at all becomes one describing it. The envelope writer applies it, so a
|
|
99
|
+
* Lambder server only ever sends the object. A reader applies it too,
|
|
100
|
+
* because what it reads is wire input no server vouches for: a hand-built
|
|
101
|
+
* mock answer (an MSW handler, a test double) or a proxy that wrote its own
|
|
102
|
+
* body can put anything there.
|
|
103
|
+
*/
|
|
104
|
+
export declare const refusalMessageOf: (message: unknown) => LambderAppRefusalMessage;
|
|
95
105
|
/**
|
|
96
106
|
* Codes the framework stamps on the refusals it authors itself, under the
|
|
97
107
|
* reserved `lambder/` prefix so app codes never collide. Compare against
|
|
@@ -103,6 +113,8 @@ export declare const LAMBDER_REFUSAL_CODES: {
|
|
|
103
113
|
readonly rateLimited: "lambder/rate-limited";
|
|
104
114
|
/** The original of an idempotent request is still processing (409). */
|
|
105
115
|
readonly duplicateInFlight: "lambder/duplicate-in-flight";
|
|
116
|
+
/** The idempotencyKey was already used for a request with a different payload (409). */
|
|
117
|
+
readonly idempotencyKeyReused: "lambder/idempotency-key-reused";
|
|
106
118
|
/** The idempotencyKey is malformed (400). */
|
|
107
119
|
readonly invalidIdempotencyKey: "lambder/invalid-idempotency-key";
|
|
108
120
|
/** No API is registered under the requested name. */
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A typed refusal: "this request is denied/invalid" as opposed to "the server
|
|
3
3
|
* crashed". Throw it from anywhere in an API call's call stack (handlers,
|
|
4
|
-
* hooks, or nested helpers
|
|
5
|
-
*
|
|
4
|
+
* hooks, or nested helpers with no access to the per-request resolver) and
|
|
5
|
+
* the render pipeline maps it onto the structured API envelope
|
|
6
6
|
* (`res.api(null, { errorMessage, notAuthorized, sessionExpired })`) instead
|
|
7
|
-
* of routing it through setGlobalErrorHandler
|
|
8
|
-
*
|
|
7
|
+
* of routing it through setGlobalErrorHandler, so refusals never reach crash
|
|
8
|
+
* logging and clients receive a parseable response.
|
|
9
9
|
*
|
|
10
10
|
* Thrown outside an API call (e.g. in a route handler) it behaves like any
|
|
11
11
|
* other error: global error handler, then the default 500.
|
|
12
12
|
*
|
|
13
13
|
* Isomorphic and dependency-free, so shared code (validators, permission
|
|
14
|
-
* checks)
|
|
15
|
-
* browser
|
|
14
|
+
* checks) used by both server and browser builds may throw it; in the
|
|
15
|
+
* browser it is just an Error.
|
|
16
16
|
*/
|
|
17
17
|
export class LambderApiRefusal extends Error {
|
|
18
18
|
/**
|
|
@@ -21,6 +21,7 @@ export class LambderApiRefusal extends Error {
|
|
|
21
21
|
* across them while this marker does not. The pipeline checks the brand.
|
|
22
22
|
*/
|
|
23
23
|
isLambderApiRefusal = true;
|
|
24
|
+
/** What the envelope's errorMessage carries: always a message object, a plain string given to the options made into one. */
|
|
24
25
|
errorMessage;
|
|
25
26
|
notAuthorized;
|
|
26
27
|
sessionExpired;
|
|
@@ -29,7 +30,7 @@ export class LambderApiRefusal extends Error {
|
|
|
29
30
|
constructor(message, options = {}) {
|
|
30
31
|
super(message, options.cause !== undefined ? { cause: options.cause } : undefined);
|
|
31
32
|
this.name = "LambderApiRefusal";
|
|
32
|
-
this.errorMessage = options.errorMessage ?? message;
|
|
33
|
+
this.errorMessage = refusalMessageOf(options.errorMessage ?? message);
|
|
33
34
|
this.notAuthorized = options.notAuthorized;
|
|
34
35
|
this.sessionExpired = options.sessionExpired;
|
|
35
36
|
this.statusCode = options.statusCode;
|
|
@@ -38,6 +39,32 @@ export class LambderApiRefusal extends Error {
|
|
|
38
39
|
}
|
|
39
40
|
/** Brand-based type guard (see LambderApiRefusal.isLambderApiRefusal). */
|
|
40
41
|
export const isLambderApiRefusal = (err) => err instanceof Error && err.isLambderApiRefusal === true;
|
|
42
|
+
const REFUSAL_MESSAGE_TYPES = ["warning", "error", "info"];
|
|
43
|
+
/**
|
|
44
|
+
* An errorMessage as the one shape a reader handles: a plain string becomes
|
|
45
|
+
* an "error" message with that content, and a value that is not a message
|
|
46
|
+
* at all becomes one describing it. The envelope writer applies it, so a
|
|
47
|
+
* Lambder server only ever sends the object. A reader applies it too,
|
|
48
|
+
* because what it reads is wire input no server vouches for: a hand-built
|
|
49
|
+
* mock answer (an MSW handler, a test double) or a proxy that wrote its own
|
|
50
|
+
* body can put anything there.
|
|
51
|
+
*/
|
|
52
|
+
export const refusalMessageOf = (message) => {
|
|
53
|
+
if (message !== null && typeof message === "object" && typeof message.content === "string") {
|
|
54
|
+
const candidate = message;
|
|
55
|
+
return REFUSAL_MESSAGE_TYPES.includes(candidate.type) ? candidate : { ...candidate, type: "error" };
|
|
56
|
+
}
|
|
57
|
+
if (typeof message === "string")
|
|
58
|
+
return { type: "error", content: message };
|
|
59
|
+
let content;
|
|
60
|
+
try {
|
|
61
|
+
content = JSON.stringify(message) ?? String(message);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
content = String(message);
|
|
65
|
+
}
|
|
66
|
+
return { type: "error", content };
|
|
67
|
+
};
|
|
41
68
|
/**
|
|
42
69
|
* Codes the framework stamps on the refusals it authors itself, under the
|
|
43
70
|
* reserved `lambder/` prefix so app codes never collide. Compare against
|
|
@@ -49,6 +76,8 @@ export const LAMBDER_REFUSAL_CODES = {
|
|
|
49
76
|
rateLimited: "lambder/rate-limited",
|
|
50
77
|
/** The original of an idempotent request is still processing (409). */
|
|
51
78
|
duplicateInFlight: "lambder/duplicate-in-flight",
|
|
79
|
+
/** The idempotencyKey was already used for a request with a different payload (409). */
|
|
80
|
+
idempotencyKeyReused: "lambder/idempotency-key-reused",
|
|
52
81
|
/** The idempotencyKey is malformed (400). */
|
|
53
82
|
invalidIdempotencyKey: "lambder/invalid-idempotency-key",
|
|
54
83
|
/** No API is registered under the requested name. */
|
|
@@ -6,9 +6,8 @@ import type { z } from "zod";
|
|
|
6
6
|
* the digest of its client-facing shape (apiSignatureOf, computed on the
|
|
7
7
|
* server side). A caller given the map sends the value with every call, and
|
|
8
8
|
* the server answers versionExpired when it differs from the digest of what
|
|
9
|
-
* it serves
|
|
10
|
-
*
|
|
11
|
-
* across deploys.
|
|
9
|
+
* it serves, so a client reloads only when an endpoint it calls has changed
|
|
10
|
+
* and otherwise keeps working across deploys.
|
|
12
11
|
*
|
|
13
12
|
* Keys are hashed so the map lists no endpoint names: the names a client
|
|
14
13
|
* calls are in its own code already, and the rest of the surface stays out
|
|
@@ -26,14 +25,12 @@ export declare const API_SIGNATURE_HEX_LENGTH = 16;
|
|
|
26
25
|
* name, cut to API_SIGNATURE_HEX_LENGTH hex characters. Async because
|
|
27
26
|
* WebCrypto's digest is, and it is the only SHA-256 a browser has.
|
|
28
27
|
*
|
|
29
|
-
* Computed on the spot
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* would grow by an entry for every name a request cared to invent and never
|
|
36
|
-
* shrink.
|
|
28
|
+
* Computed on the spot every time, with nothing kept. The digest that
|
|
29
|
+
* describes an endpoint is the generator's, computed once at build time;
|
|
30
|
+
* this is one hash of a short name, nothing beside the request it belongs
|
|
31
|
+
* to. A cache would be keyed by name, and on the server the name comes off
|
|
32
|
+
* the wire before anything checks that it is an endpoint, so the cache would
|
|
33
|
+
* gain an entry for every name a request cared to invent and never shrink.
|
|
37
34
|
*/
|
|
38
35
|
export declare const apiNameKeyOf: (apiName: string) => Promise<string>;
|
|
39
36
|
/** The map's signature for one endpoint, or null when the map holds none for it. */
|
|
@@ -59,23 +56,22 @@ export declare const EXTENSIBLE_ENUM_META_KEY = "x-lambder-extensible-enum";
|
|
|
59
56
|
* widely returned payload (a session, a profile) then reloads only the
|
|
60
57
|
* clients that send the list back, not every client that reads it.
|
|
61
58
|
*
|
|
62
|
-
* Where the enum is input its values still count:
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
59
|
+
* Where the enum is input its values still count: an older client may still
|
|
60
|
+
* send a value dropped from the list, which the server would refuse, so that
|
|
61
|
+
* endpoint's clients must reload. Everything else about the schema is
|
|
62
|
+
* untouched: its type, its validation on both sides, and what it is outside
|
|
63
|
+
* the digest.
|
|
67
64
|
*
|
|
68
65
|
* The mark is a promise the schema makes for its readers, and nothing checks
|
|
69
66
|
* it. A client that switches over every value with no fallback, or indexes a
|
|
70
|
-
* map by one, renders
|
|
71
|
-
*
|
|
67
|
+
* map by one, renders an unknown value as nothing, or throws. Mark only a
|
|
68
|
+
* list whose every reader handles an unknown value on purpose.
|
|
72
69
|
*
|
|
73
70
|
* It is zod metadata (`.meta()`), which zod keeps in one registry on
|
|
74
71
|
* 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
|
|
76
|
-
* marked enum
|
|
77
|
-
*
|
|
78
|
-
* one.
|
|
72
|
+
* even when the server resolves another copy of zod. A schema rebuilt from a
|
|
73
|
+
* marked enum (`z.enum(marked.options)`, `.exclude()`) carries no mark and
|
|
74
|
+
* counts in full, which costs a reload, never a missed one.
|
|
79
75
|
*
|
|
80
76
|
* @example
|
|
81
77
|
* export const RoleSchema = extensibleEnum(z.enum(["admin", "member"]));
|
|
@@ -12,14 +12,12 @@ const API_NAME_KEY_PREFIX = "lambder-api-name:";
|
|
|
12
12
|
* name, cut to API_SIGNATURE_HEX_LENGTH hex characters. Async because
|
|
13
13
|
* WebCrypto's digest is, and it is the only SHA-256 a browser has.
|
|
14
14
|
*
|
|
15
|
-
* Computed on the spot
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* would grow by an entry for every name a request cared to invent and never
|
|
22
|
-
* shrink.
|
|
15
|
+
* Computed on the spot every time, with nothing kept. The digest that
|
|
16
|
+
* describes an endpoint is the generator's, computed once at build time;
|
|
17
|
+
* this is one hash of a short name, nothing beside the request it belongs
|
|
18
|
+
* to. A cache would be keyed by name, and on the server the name comes off
|
|
19
|
+
* the wire before anything checks that it is an endpoint, so the cache would
|
|
20
|
+
* gain an entry for every name a request cared to invent and never shrink.
|
|
23
21
|
*/
|
|
24
22
|
export const apiNameKeyOf = async (apiName) => (await sha256HexOf(API_NAME_KEY_PREFIX + apiName)).slice(0, API_SIGNATURE_HEX_LENGTH);
|
|
25
23
|
/** The map's signature for one endpoint, or null when the map holds none for it. */
|
|
@@ -57,23 +55,22 @@ export const EXTENSIBLE_ENUM_META_KEY = "x-lambder-extensible-enum";
|
|
|
57
55
|
* widely returned payload (a session, a profile) then reloads only the
|
|
58
56
|
* clients that send the list back, not every client that reads it.
|
|
59
57
|
*
|
|
60
|
-
* Where the enum is input its values still count:
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
58
|
+
* Where the enum is input its values still count: an older client may still
|
|
59
|
+
* send a value dropped from the list, which the server would refuse, so that
|
|
60
|
+
* endpoint's clients must reload. Everything else about the schema is
|
|
61
|
+
* untouched: its type, its validation on both sides, and what it is outside
|
|
62
|
+
* the digest.
|
|
65
63
|
*
|
|
66
64
|
* The mark is a promise the schema makes for its readers, and nothing checks
|
|
67
65
|
* it. A client that switches over every value with no fallback, or indexes a
|
|
68
|
-
* map by one, renders
|
|
69
|
-
*
|
|
66
|
+
* map by one, renders an unknown value as nothing, or throws. Mark only a
|
|
67
|
+
* list whose every reader handles an unknown value on purpose.
|
|
70
68
|
*
|
|
71
69
|
* It is zod metadata (`.meta()`), which zod keeps in one registry on
|
|
72
70
|
* 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
|
|
74
|
-
* marked enum
|
|
75
|
-
*
|
|
76
|
-
* one.
|
|
71
|
+
* even when the server resolves another copy of zod. A schema rebuilt from a
|
|
72
|
+
* marked enum (`z.enum(marked.options)`, `.exclude()`) carries no mark and
|
|
73
|
+
* counts in full, which costs a reload, never a missed one.
|
|
77
74
|
*
|
|
78
75
|
* @example
|
|
79
76
|
* export const RoleSchema = extensibleEnum(z.enum(["admin", "member"]));
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The per-call options
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* it.
|
|
2
|
+
* The per-call options, the contract-driven typing of a call's arguments, and
|
|
3
|
+
* the runtime merge of guard inputs, shared by the browser caller
|
|
4
|
+
* (LambderCaller) and the server-side invoke caller (LambderInvokeCaller).
|
|
5
|
+
* Both speak the same envelope to the same kind of contract, so what an API
|
|
6
|
+
* demands of its caller (a guardInput-mode guard's value, say) is decided
|
|
7
|
+
* here once and the two cannot drift. Pure types and one dependency-free
|
|
8
|
+
* function, so the browser entry resolves it.
|
|
10
9
|
*/
|
|
11
10
|
import type { LambderContractIdempotencyOf } from "./LambderApiContract.js";
|
|
11
|
+
import type { LambderIdempotencyKeyScope } from "./LambderIdempotencyKeyScope.js";
|
|
12
12
|
type IsAny<T> = 0 extends (1 & T) ? true : false;
|
|
13
13
|
/**
|
|
14
14
|
* The options every call takes, whichever caller sends it. Each caller adds
|
|
@@ -40,19 +40,22 @@ export type LambderSharedCallOptions = {
|
|
|
40
40
|
/**
|
|
41
41
|
* Replay-protection key for APIs declared idempotent on the server.
|
|
42
42
|
* Generate once per logical operation with
|
|
43
|
-
*
|
|
43
|
+
* createIdempotencyKey() and send the same key on retries:
|
|
44
44
|
* duplicates of an in-flight request refuse, and repeats of a completed
|
|
45
45
|
* one replay its stored response instead of re-executing. Must be
|
|
46
46
|
* UNGUESSABLE random (it scopes the replay record for logged-out clients)
|
|
47
47
|
* and at least 16 characters; the server refuses shorter keys with a 400.
|
|
48
48
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
49
|
+
* REQUIRED by the typed contract for an API that declares `idempotency`,
|
|
50
|
+
* since the server runs a keyless call unprotected.
|
|
51
|
+
*
|
|
52
|
+
* A key scope (createIdempotencyKeyScope()) is the easier
|
|
53
|
+
* form: it rotates once an answer settles the operation, so a retry after
|
|
54
|
+
* a dropped connection reuses the key and the next attempt (a corrected
|
|
55
|
+
* form after a refusal included) gets a new one. See
|
|
56
|
+
* LambderIdempotencyKeyScope for which answers settle it.
|
|
54
57
|
*/
|
|
55
|
-
idempotencyKey?: string;
|
|
58
|
+
idempotencyKey?: string | LambderIdempotencyKeyScope;
|
|
56
59
|
};
|
|
57
60
|
/** The payload type one API of a contract takes; `any` for an untyped caller or a name the contract does not know. */
|
|
58
61
|
type LambderContractInputOf<TContract, TApiName> = IsAny<TContract> extends true ? any : TApiName extends keyof TContract ? TContract[TApiName] extends {
|
|
@@ -80,12 +83,12 @@ export type LambderProvidedGuardInputs<TContract, TProvided extends string> = Is
|
|
|
80
83
|
};
|
|
81
84
|
/**
|
|
82
85
|
* Supplies guardInputs for every call from one place (the organization the
|
|
83
|
-
* UI is on, a device token), keyed by guard name; per-call guardInputs
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
86
|
+
* UI is on, a device token), keyed by guard name; per-call guardInputs merge
|
|
87
|
+
* on top. Name the guards it covers in the caller's second type parameter,
|
|
88
|
+
* `new LambderCaller<Contract, "orgPermission">`, and calls to APIs whose
|
|
89
|
+
* guardInput guards are all covered do not require the options argument.
|
|
90
|
+
* May be async; a throw fails the call as an unknown error before anything
|
|
91
|
+
* is sent.
|
|
89
92
|
*/
|
|
90
93
|
export type LambderGuardInputsProvider<TContract, TProvided extends string> = (apiName: keyof TContract & string) => LambderProvidedGuardInputs<TContract, TProvided> | Promise<LambderProvidedGuardInputs<TContract, TProvided>>;
|
|
91
94
|
/** Optional until the caller names provided guards: naming them without a provider would send nothing. */
|
|
@@ -117,11 +120,10 @@ type ContractGuardInputsField<TEntry, TProvided extends string> = [
|
|
|
117
120
|
*
|
|
118
121
|
* Read as "required unless it says false" rather than "required only when it
|
|
119
122
|
* says true", so an entry whose option widened to `boolean` (declared through
|
|
120
|
-
* a spread, or built in a helper) keeps the requirement
|
|
121
|
-
* losing its compile-time half.
|
|
123
|
+
* a spread, or built in a helper) keeps the requirement at compile time.
|
|
122
124
|
*/
|
|
123
125
|
type ContractIdempotencyKeyField<TContract, TApiName> = TApiName extends keyof TContract ? [LambderContractIdempotencyOf<TContract, TApiName>] extends [never] ? {} : [LambderContractIdempotencyOf<TContract, TApiName>] extends [false] ? {} : {
|
|
124
|
-
idempotencyKey: string;
|
|
126
|
+
idempotencyKey: string | LambderIdempotencyKeyScope;
|
|
125
127
|
} : {};
|
|
126
128
|
/**
|
|
127
129
|
* What the contract adds to one call's options: the guardInputs field and the
|
|
@@ -131,41 +133,30 @@ type ContractCallFields<TContract, TApiName, TProvided extends string> = TApiNam
|
|
|
131
133
|
/**
|
|
132
134
|
* The options argument of one call: optional normally, REQUIRED when the
|
|
133
135
|
* contract demands something of it, so forgetting a guard's value or an
|
|
134
|
-
* idempotent API's key is a compile error
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
* `{} extends TFields` is the question "is every field the contract added
|
|
139
|
-
* optional": an empty object is assignable to a type whose properties are all
|
|
140
|
-
* optional and to nothing else.
|
|
136
|
+
* idempotent API's key is a compile error rather than a 422 or a replay that
|
|
137
|
+
* never happens. TOptions is the caller's own per-call options type.
|
|
138
|
+
* `{} extends TFields` asks "is every field the contract added optional".
|
|
141
139
|
*/
|
|
142
140
|
type LambderCallOptionsArg<TContract, TApiName, TProvided extends string, TOptions extends {
|
|
143
141
|
guardInputs?: Record<string, unknown>;
|
|
144
142
|
}> = IsAny<TContract> extends true ? [options?: TOptions] : TApiName extends keyof TContract ? ContractCallFields<TContract, TApiName, TProvided> extends infer TFields ? {} extends TFields ? [options?: TOptions & TFields] : [options: TOptions & TFields] : never : [options?: TOptions];
|
|
145
143
|
/**
|
|
146
144
|
* Everything one call passes after the API name: the payload, then the
|
|
147
|
-
* options, both decided by the contract.
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
* conditional otherwise.
|
|
155
|
-
*
|
|
156
|
-
* When the options argument is itself mandatory (an uncovered guardInput
|
|
157
|
-
* guard, an idempotent API's key), the payload cannot stay optional in front
|
|
158
|
-
* of it, since a tuple's required element may not follow an optional one.
|
|
159
|
-
* Such a call passes its payload explicitly, `undefined` included.
|
|
145
|
+
* options, both decided by the contract. The payload is optional only when
|
|
146
|
+
* the API's input accepts undefined, so `caller.api("getUser")` against
|
|
147
|
+
* `input: { id: string }` fails to compile rather than drawing a 422. That
|
|
148
|
+
* needs one rest tuple, since TypeScript has no other per-argument
|
|
149
|
+
* conditional. When the options are mandatory, the payload cannot stay
|
|
150
|
+
* optional before them (a tuple's required element may not follow an
|
|
151
|
+
* optional one), so such a call passes it explicitly, `undefined` included.
|
|
160
152
|
*/
|
|
161
153
|
export type LambderCallArgs<TContract, TApiName, TProvided extends string, TOptions extends {
|
|
162
154
|
guardInputs?: Record<string, unknown>;
|
|
163
155
|
}> = LambderCallOptionsArg<TContract, TApiName, TProvided, TOptions> extends [options: infer TRequired] ? [payload: LambderContractInputOf<TContract, TApiName>, options: TRequired] : LambderCallOptionsArg<TContract, TApiName, TProvided, TOptions> extends [options?: infer TOptional] ? undefined extends LambderContractInputOf<TContract, TApiName> ? [payload?: LambderContractInputOf<TContract, TApiName>, options?: TOptional] : [payload: LambderContractInputOf<TContract, TApiName>, options?: TOptional] : never;
|
|
164
156
|
/**
|
|
165
157
|
* Provider values underneath, per-call values on top; undefined when neither
|
|
166
|
-
* side supplied any. Synchronous
|
|
167
|
-
*
|
|
168
|
-
* request in the same tick it was made.
|
|
158
|
+
* side supplied any. Synchronous so a call without a provider still issues
|
|
159
|
+
* its request in the same tick it was made.
|
|
169
160
|
*/
|
|
170
161
|
export declare const mergeGuardInputs: (provided: Record<string, unknown> | undefined, perCall: Record<string, unknown> | undefined) => Record<string, unknown> | undefined;
|
|
171
162
|
export {};
|