lambder 7.2.5 → 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 +1021 -3
- package/README.md +43 -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 +74 -61
- package/dist/api/LambderApiIdempotency.js +226 -151
- package/dist/api/LambderApiOutputValidationError.d.ts +32 -0
- package/dist/api/LambderApiOutputValidationError.js +50 -0
- package/dist/api/LambderApiPipeline.d.ts +77 -39
- package/dist/api/LambderApiPipeline.js +135 -62
- package/dist/api/LambderApiRateLimits.d.ts +208 -54
- package/dist/api/LambderApiRateLimits.js +197 -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 +161 -69
- package/dist/core/Lambder.js +370 -226
- 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 +28 -7
- package/dist/core/LambderFiles.js +73 -33
- 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 +44 -10
- package/dist/invoke/LambderLambdaEvent.js +80 -37
- package/dist/invoke/lambderHandlerTransport.d.ts +12 -10
- package/dist/invoke/lambderHandlerTransport.js +16 -19
- 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 +3 -1
- package/dist/mock.js +5 -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 +136 -47
- package/dist/session/LambderSessionManager.js +280 -139
- 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/LambderTestingDoors.d.ts +29 -0
- package/dist/shared/util/LambderTestingDoors.js +29 -0
- 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 +79 -0
- package/dist/shared/wire/LambderOutcomeAssertions.js +112 -0
- 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 +176 -0
- package/dist/testing/LambderTestApp.js +204 -0
- package/dist/testing/LambderTestVisitor.d.ts +153 -0
- package/dist/testing/LambderTestVisitor.js +154 -0
- package/dist/testing.d.ts +27 -0
- package/dist/testing.js +24 -0
- package/package.json +20 -3
- package/dist/api/LambderApiPolicyEngine.d.ts +0 -36
- package/dist/api/LambderApiPolicyEngine.js +0 -77
- package/dist/shared/util/LambderKeyFields.d.ts +0 -32
- package/dist/shared/util/LambderKeyFields.js +0 -34
|
@@ -9,7 +9,7 @@ export type { LambderApiOutcome, LambderApiFailureReason, LambderValidationError
|
|
|
9
9
|
export type { LambderProvidedGuardInputs, LambderGuardInputsProvider } from '../shared/wire/LambderCallOptions.js';
|
|
10
10
|
/** A handler told that something happened, with nothing to hand it. */
|
|
11
11
|
type NotifyHandler = () => void | Promise<void>;
|
|
12
|
-
/** One call in flight: pushed when it starts, removed when it settles, so the list
|
|
12
|
+
/** One call in flight: pushed when it starts, removed when it settles, so the list holds only calls in flight. */
|
|
13
13
|
type FetchTracker = {
|
|
14
14
|
apiName: string;
|
|
15
15
|
};
|
|
@@ -30,15 +30,10 @@ type FetchEndEventHandler = (params: {
|
|
|
30
30
|
type ErrorHandler = (err: Error) => void | Promise<void>;
|
|
31
31
|
type ValidationErrorHandler = (zodError: LambderValidationError) => (void | false) | Promise<(void | false)>;
|
|
32
32
|
type MessageHandler = (message: LambderAppRefusalMessage | string) => void | Promise<void>;
|
|
33
|
+
/** Handed the refusal as its message object, a plain-string errorMessage having been read as one (refusalMessageOf). */
|
|
34
|
+
type ErrorMessageHandler = (message: LambderAppRefusalMessage) => void | Promise<void>;
|
|
33
35
|
/** The logListHandler option: an answer's logList, success or failure, when it has entries. The invoke caller's onLogList, for a browser. */
|
|
34
36
|
export type LambderLogListHandler = (apiName: string, logList: unknown[]) => void | Promise<void>;
|
|
35
|
-
/** One logical operation's rotating idempotency key: see LambderCaller.createIdempotencyKeyScope(). */
|
|
36
|
-
export type LambderIdempotencyKeyScope = {
|
|
37
|
-
/** The key for the operation currently in progress. */
|
|
38
|
-
readonly current: string;
|
|
39
|
-
/** Call after a confirmed success: the next operation is a new intent. Returns the new key. */
|
|
40
|
-
rotate(): string;
|
|
41
|
-
};
|
|
42
37
|
/**
|
|
43
38
|
* Per-call options: the request extras both callers share (see
|
|
44
39
|
* LambderSharedCallOptions) plus an override for every constructor handler.
|
|
@@ -47,7 +42,7 @@ export type LambderCallOptions = LambderSharedCallOptions & {
|
|
|
47
42
|
versionExpiredHandler?: NotifyHandler;
|
|
48
43
|
sessionExpiredHandler?: NotifyHandler;
|
|
49
44
|
messageHandler?: MessageHandler;
|
|
50
|
-
errorMessageHandler?:
|
|
45
|
+
errorMessageHandler?: ErrorMessageHandler;
|
|
51
46
|
apiInputValidationErrorHandler?: ValidationErrorHandler;
|
|
52
47
|
notAuthorizedHandler?: NotifyHandler;
|
|
53
48
|
errorHandler?: ErrorHandler;
|
|
@@ -67,13 +62,19 @@ type LambderCallerBaseOptions = {
|
|
|
67
62
|
* it out and no call is gated.
|
|
68
63
|
*/
|
|
69
64
|
apiSignatures?: LambderApiSignatureMap;
|
|
70
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Send credentialed cross-origin requests (fetch's `cors` mode, cookies
|
|
67
|
+
* included). Default: exactly when apiPath is an absolute URL on another
|
|
68
|
+
* origin than the page's, which is when a browser needs it. Ignored when
|
|
69
|
+
* a transport is passed.
|
|
70
|
+
*/
|
|
71
|
+
isCorsEnabled?: boolean;
|
|
71
72
|
/** Default per-request timeout in ms (none unless set; API Gateway caps around 29s, so ~30000 is a sensible value). Overridable per call. */
|
|
72
73
|
timeoutMs?: number;
|
|
73
74
|
versionExpiredHandler?: NotifyHandler;
|
|
74
75
|
sessionExpiredHandler?: NotifyHandler;
|
|
75
76
|
messageHandler?: MessageHandler;
|
|
76
|
-
errorMessageHandler?:
|
|
77
|
+
errorMessageHandler?: ErrorMessageHandler;
|
|
77
78
|
notAuthorizedHandler?: NotifyHandler;
|
|
78
79
|
errorHandler?: ErrorHandler;
|
|
79
80
|
/** Receives each answer's logList, with the API name. Default: console.log with a `[lambder]` prefix, one line per entry. */
|
|
@@ -108,16 +109,13 @@ export type LambderCallerOptions<TContract, TProvided extends string = never> =
|
|
|
108
109
|
* @typeParam TProvidedGuards - Guard names guardInputsProvider covers; those APIs' options argument becomes optional.
|
|
109
110
|
*/
|
|
110
111
|
export default class LambderCaller<TContract extends LambderApiContractShape = any, TProvidedGuards extends string = never> {
|
|
111
|
-
private isCorsEnabled;
|
|
112
112
|
private apiPath;
|
|
113
113
|
private apiVersion?;
|
|
114
114
|
private apiSignatures?;
|
|
115
115
|
private timeoutMs?;
|
|
116
|
-
/** What keeps a stale bundle from reloading itself forever; see the class. */
|
|
117
|
-
private readonly reloadLoopBreaker;
|
|
118
116
|
/** The calls currently in flight, in the order they started. */
|
|
119
117
|
fetchTrackerList: FetchTracker[];
|
|
120
|
-
/** Whether any call is in flight. Derived
|
|
118
|
+
/** Whether any call is in flight. Derived from the list, so the two cannot drift apart. */
|
|
121
119
|
get isLoading(): boolean;
|
|
122
120
|
private versionExpiredHandler?;
|
|
123
121
|
private sessionExpiredHandler?;
|
|
@@ -139,35 +137,6 @@ export default class LambderCaller<TContract extends LambderApiContractShape = a
|
|
|
139
137
|
setSessionCookieKey(sessionTokenCookieKey: string, sessionCsrfCookieKey: string): void;
|
|
140
138
|
/** Replaces how calls reach the server: a mock runtime, an in-process handler, a decorated transport. */
|
|
141
139
|
setTransport(transport: LambderApiTransport): this;
|
|
142
|
-
/**
|
|
143
|
-
* A self-rotating idempotency key for a component or form that performs
|
|
144
|
-
* the same logical operation repeatedly. `current` is the key for the
|
|
145
|
-
* operation in progress: send it with every attempt (first try, retry
|
|
146
|
-
* after a failure, double-tap) so the server collapses them. Call
|
|
147
|
-
* `rotate()` after a confirmed success so the next operation is a new
|
|
148
|
-
* intent with its own key.
|
|
149
|
-
*
|
|
150
|
-
* ```typescript
|
|
151
|
-
* const submitKey = LambderCaller.createIdempotencyKeyScope();
|
|
152
|
-
* await caller.api("order.create", payload, { idempotencyKey: submitKey.current });
|
|
153
|
-
* submitKey.rotate();
|
|
154
|
-
* ```
|
|
155
|
-
*/
|
|
156
|
-
static createIdempotencyKeyScope(): LambderIdempotencyKeyScope;
|
|
157
|
-
/**
|
|
158
|
-
* Generate an idempotency key for one logical operation. Create it when
|
|
159
|
-
* the operation begins (a form opens, a draft starts), send the same key
|
|
160
|
-
* on every attempt of that operation, and generate a new one after a
|
|
161
|
-
* confirmed success. Uses crypto.randomUUID when available and falls back
|
|
162
|
-
* to a v4 UUID from getRandomValues, because randomUUID only exists in
|
|
163
|
-
* secure contexts (plain-http LAN device testing lacks it).
|
|
164
|
-
*
|
|
165
|
-
* A runtime with neither throws rather than reaching for Math.random: the
|
|
166
|
-
* key must be UNGUESSABLE, since it is what scopes the replay record for a
|
|
167
|
-
* logged-out client, and a guessable one hands that client's stored
|
|
168
|
-
* response to whoever guesses it.
|
|
169
|
-
*/
|
|
170
|
-
static createIdempotencyKey(): string;
|
|
171
140
|
private clearSessionCookies;
|
|
172
141
|
/** One call, one outcome. Never throws; every failure path resolves to { ok: false }. */
|
|
173
142
|
private dispatch;
|
|
@@ -3,6 +3,7 @@ import { compressPayloadGzip, isRequestCompressionAvailable, resolveRequestCompr
|
|
|
3
3
|
import { resolveCompressionOption } from '../shared/wire/LambderCompressionOption.js';
|
|
4
4
|
import { resolveApiOutcome } from '../shared/wire/LambderApiOutcome.js';
|
|
5
5
|
import { mergeGuardInputs, } from '../shared/wire/LambderCallOptions.js';
|
|
6
|
+
import { beginIdempotentAttempt, IDEMPOTENT_ATTEMPT_NOT_SENT } from '../shared/wire/LambderIdempotencyKeyScope.js';
|
|
6
7
|
import { createCallAbort } from '../shared/util/LambderCallAbort.js';
|
|
7
8
|
import { coerceToError } from '../shared/wire/LambderCrashDetail.js';
|
|
8
9
|
import { isLambderTransportFailure } from '../shared/transport/LambderApiTransport.js';
|
|
@@ -10,21 +11,25 @@ import { DEFAULT_SESSION_TOKEN_COOKIE_KEY, DEFAULT_SESSION_CSRF_COOKIE_KEY } fro
|
|
|
10
11
|
import { readApiSignature } from '../shared/wire/LambderApiSignature.js';
|
|
11
12
|
import { LambderReloadLoopBreaker, RELOAD_LOOP_WINDOW_MS } from './LambderReloadLoopBreaker.js';
|
|
12
13
|
import { lambderFetchTransport } from './lambderFetchTransport.js';
|
|
14
|
+
/**
|
|
15
|
+
* What keeps a stale bundle from reloading itself forever (see the class):
|
|
16
|
+
* one for the page, shared by every caller it builds, since a reload is the
|
|
17
|
+
* page's. Held per caller, two callers would each run an ask of their own at
|
|
18
|
+
* the same time.
|
|
19
|
+
*/
|
|
20
|
+
const pageReloadLoopBreaker = new LambderReloadLoopBreaker();
|
|
13
21
|
/**
|
|
14
22
|
* @typeParam TContract - The API contract, for typed names, payloads and guard inputs.
|
|
15
23
|
* @typeParam TProvidedGuards - Guard names guardInputsProvider covers; those APIs' options argument becomes optional.
|
|
16
24
|
*/
|
|
17
25
|
export default class LambderCaller {
|
|
18
|
-
isCorsEnabled;
|
|
19
26
|
apiPath;
|
|
20
27
|
apiVersion;
|
|
21
28
|
apiSignatures;
|
|
22
29
|
timeoutMs;
|
|
23
|
-
/** What keeps a stale bundle from reloading itself forever; see the class. */
|
|
24
|
-
reloadLoopBreaker = new LambderReloadLoopBreaker();
|
|
25
30
|
/** The calls currently in flight, in the order they started. */
|
|
26
31
|
fetchTrackerList = [];
|
|
27
|
-
/** Whether any call is in flight. Derived
|
|
32
|
+
/** Whether any call is in flight. Derived from the list, so the two cannot drift apart. */
|
|
28
33
|
get isLoading() { return this.fetchTrackerList.length > 0; }
|
|
29
34
|
versionExpiredHandler;
|
|
30
35
|
sessionExpiredHandler;
|
|
@@ -49,12 +54,11 @@ export default class LambderCaller {
|
|
|
49
54
|
this.apiPath = apiPath;
|
|
50
55
|
this.apiVersion = apiVersion;
|
|
51
56
|
this.apiSignatures = apiSignatures;
|
|
52
|
-
this.isCorsEnabled = isCorsEnabled;
|
|
53
57
|
this.timeoutMs = timeoutMs;
|
|
54
58
|
this.sessionCookieDomain = sessionCookieDomain;
|
|
55
59
|
// `?? false`: unlike the at-rest stores, this one is off unless asked for.
|
|
56
60
|
this.requestCompression = resolveCompressionOption(requestCompression ?? false, DEFAULT_REQUEST_COMPRESSION_SETTINGS);
|
|
57
|
-
this.transport = transport ?? lambderFetchTransport({ cors:
|
|
61
|
+
this.transport = transport ?? lambderFetchTransport({ cors: isCorsEnabled });
|
|
58
62
|
this.versionExpiredHandler = versionExpiredHandler;
|
|
59
63
|
this.sessionExpiredHandler = sessionExpiredHandler;
|
|
60
64
|
this.messageHandler = messageHandler;
|
|
@@ -77,53 +81,6 @@ export default class LambderCaller {
|
|
|
77
81
|
this.transport = transport;
|
|
78
82
|
return this;
|
|
79
83
|
}
|
|
80
|
-
/**
|
|
81
|
-
* A self-rotating idempotency key for a component or form that performs
|
|
82
|
-
* the same logical operation repeatedly. `current` is the key for the
|
|
83
|
-
* operation in progress: send it with every attempt (first try, retry
|
|
84
|
-
* after a failure, double-tap) so the server collapses them. Call
|
|
85
|
-
* `rotate()` after a confirmed success so the next operation is a new
|
|
86
|
-
* intent with its own key.
|
|
87
|
-
*
|
|
88
|
-
* ```typescript
|
|
89
|
-
* const submitKey = LambderCaller.createIdempotencyKeyScope();
|
|
90
|
-
* await caller.api("order.create", payload, { idempotencyKey: submitKey.current });
|
|
91
|
-
* submitKey.rotate();
|
|
92
|
-
* ```
|
|
93
|
-
*/
|
|
94
|
-
static createIdempotencyKeyScope() {
|
|
95
|
-
let key = LambderCaller.createIdempotencyKey();
|
|
96
|
-
return {
|
|
97
|
-
get current() { return key; },
|
|
98
|
-
rotate() { key = LambderCaller.createIdempotencyKey(); return key; },
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
/**
|
|
102
|
-
* Generate an idempotency key for one logical operation. Create it when
|
|
103
|
-
* the operation begins (a form opens, a draft starts), send the same key
|
|
104
|
-
* on every attempt of that operation, and generate a new one after a
|
|
105
|
-
* confirmed success. Uses crypto.randomUUID when available and falls back
|
|
106
|
-
* to a v4 UUID from getRandomValues, because randomUUID only exists in
|
|
107
|
-
* secure contexts (plain-http LAN device testing lacks it).
|
|
108
|
-
*
|
|
109
|
-
* A runtime with neither throws rather than reaching for Math.random: the
|
|
110
|
-
* key must be UNGUESSABLE, since it is what scopes the replay record for a
|
|
111
|
-
* logged-out client, and a guessable one hands that client's stored
|
|
112
|
-
* response to whoever guesses it.
|
|
113
|
-
*/
|
|
114
|
-
static createIdempotencyKey() {
|
|
115
|
-
const cryptoObj = globalThis.crypto;
|
|
116
|
-
if (cryptoObj?.randomUUID)
|
|
117
|
-
return cryptoObj.randomUUID();
|
|
118
|
-
if (!cryptoObj?.getRandomValues)
|
|
119
|
-
throw new Error("LambderCaller.createIdempotencyKey needs crypto.getRandomValues: an idempotency key must be unguessable, and this runtime offers no random source that is.");
|
|
120
|
-
const bytes = new Uint8Array(16);
|
|
121
|
-
cryptoObj.getRandomValues(bytes);
|
|
122
|
-
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
123
|
-
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
124
|
-
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
125
|
-
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
126
|
-
}
|
|
127
84
|
clearSessionCookies() {
|
|
128
85
|
const domainOption = this.sessionCookieDomain;
|
|
129
86
|
const hostname = globalThis.location?.hostname ?? "";
|
|
@@ -153,11 +110,17 @@ export default class LambderCaller {
|
|
|
153
110
|
const fetchEndedHandler = options?.fetchEndedHandler ?? this.fetchEndedHandler;
|
|
154
111
|
const headers = options?.headers;
|
|
155
112
|
const fetchTracker = { apiName };
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
|
|
113
|
+
// A key scope sends its current key and is told how the attempt
|
|
114
|
+
// ended, before any handler runs: a handler that throws must not
|
|
115
|
+
// leave an unanswered attempt untold. A plain key is sent as it is.
|
|
116
|
+
const idempotentAttempt = beginIdempotentAttempt(options?.idempotencyKey);
|
|
117
|
+
const idempotencyKey = idempotentAttempt.key;
|
|
118
|
+
let sent = false;
|
|
119
|
+
// Dropped the moment the call settles, idempotently, since the finally
|
|
120
|
+
// block below also runs it for the paths fetchEnded never reaches. A
|
|
121
|
+
// tracker left in would grow the list by one per call, and every
|
|
122
|
+
// handler call copies the list, so a long-lived page would pay more
|
|
123
|
+
// per call the longer it stayed open.
|
|
161
124
|
const dropFetchTracker = () => {
|
|
162
125
|
const at = this.fetchTrackerList.indexOf(fetchTracker);
|
|
163
126
|
if (at !== -1)
|
|
@@ -191,6 +154,7 @@ export default class LambderCaller {
|
|
|
191
154
|
const failure = abort.abortFailure(stage);
|
|
192
155
|
if (!failure)
|
|
193
156
|
return null;
|
|
157
|
+
idempotentAttempt.settle(stage === "beforeSending" ? IDEMPOTENT_ATTEMPT_NOT_SENT : { ok: false, reason: failure.reason });
|
|
194
158
|
await fetchEnded(failure.error);
|
|
195
159
|
await reportError(failure.error);
|
|
196
160
|
const outcome = { ok: false, reason: failure.reason, error: failure.error };
|
|
@@ -208,10 +172,6 @@ export default class LambderCaller {
|
|
|
208
172
|
// carries the map. A name the map lacks fails the call here, as a
|
|
209
173
|
// provider that threw would: the map predates the endpoint.
|
|
210
174
|
const signature = this.apiSignatures ? await readApiSignature(this.apiSignatures, apiName) : undefined;
|
|
211
|
-
// js-cookie reads nothing without a document, and there is no
|
|
212
|
-
// location outside a page: both are "" then, and a transport that
|
|
213
|
-
// carries a cookie jar fills the token in from it.
|
|
214
|
-
const token = Cookies.get(this.sessionCsrfCookieKey) || "";
|
|
215
175
|
const siteHost = globalThis.location?.hostname ?? "";
|
|
216
176
|
// Provider values underneath, per-call values on top.
|
|
217
177
|
const providedGuardInputs = this.guardInputsProvider
|
|
@@ -233,8 +193,16 @@ export default class LambderCaller {
|
|
|
233
193
|
const refused = await abandonedOutcome("beforeSending");
|
|
234
194
|
if (refused)
|
|
235
195
|
return refused;
|
|
196
|
+
// Read last, right before the send: the browser attaches the
|
|
197
|
+
// cookies as the request leaves, and a rotation answered while a
|
|
198
|
+
// provider above was awaited would otherwise pair the new session
|
|
199
|
+
// cookie with the old token. js-cookie reads nothing without a
|
|
200
|
+
// document: the token is "" then, and a transport that carries a
|
|
201
|
+
// cookie jar fills it in from there.
|
|
202
|
+
const token = Cookies.get(this.sessionCsrfCookieKey) || "";
|
|
236
203
|
let answer;
|
|
237
204
|
try {
|
|
205
|
+
sent = true;
|
|
238
206
|
answer = await this.transport({
|
|
239
207
|
apiPath: this.apiPath,
|
|
240
208
|
apiName, version, token, siteHost,
|
|
@@ -242,15 +210,13 @@ export default class LambderCaller {
|
|
|
242
210
|
csrfCookieKey: this.sessionCsrfCookieKey,
|
|
243
211
|
...(compressedPayload ? { compressed: compressedPayload } : { payload }),
|
|
244
212
|
...(guardInputs !== undefined ? { guardInputs } : {}),
|
|
245
|
-
...(
|
|
213
|
+
...(idempotencyKey !== undefined ? { idempotencyKey } : {}),
|
|
246
214
|
...(headers ? { headers } : {}),
|
|
247
215
|
...(signal ? { signal } : {}),
|
|
248
216
|
});
|
|
249
217
|
}
|
|
250
218
|
catch (err) {
|
|
251
219
|
const wrappedError = coerceToError(err, "Request failed");
|
|
252
|
-
await fetchEnded(wrappedError);
|
|
253
|
-
await reportError(wrappedError);
|
|
254
220
|
// The caller's own abort wins, since only it knows about that.
|
|
255
221
|
// Otherwise a transport that named its reason is believed:
|
|
256
222
|
// "protocol" means something came back and was not an answer,
|
|
@@ -258,6 +224,9 @@ export default class LambderCaller {
|
|
|
258
224
|
const reason = abort.timedOut() ? 'timeout'
|
|
259
225
|
: isLambderTransportFailure(err) && err.reason === 'protocol' ? 'server'
|
|
260
226
|
: 'network';
|
|
227
|
+
idempotentAttempt.settle({ ok: false, reason });
|
|
228
|
+
await fetchEnded(wrappedError);
|
|
229
|
+
await reportError(wrappedError);
|
|
261
230
|
return { ok: false, reason, error: wrappedError };
|
|
262
231
|
}
|
|
263
232
|
// An answer that arrives after the call was given up on is not a
|
|
@@ -270,11 +239,11 @@ export default class LambderCaller {
|
|
|
270
239
|
// The reading of the answer is shared with LambderInvokeCaller;
|
|
271
240
|
// only what to do about each outcome is this caller's.
|
|
272
241
|
const outcome = await resolveApiOutcome(answer);
|
|
242
|
+
idempotentAttempt.settle(outcome);
|
|
273
243
|
// Every answer's logs, surfaced once and before any branch that
|
|
274
|
-
// returns:
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
// answer that never reached logListHandler at all.
|
|
244
|
+
// returns: a 500 whose global error handler attached a crash and a
|
|
245
|
+
// logList has the log trail worth the most, and it returns early
|
|
246
|
+
// on the `server` branch below.
|
|
278
247
|
const logList = outcome.logList;
|
|
279
248
|
if (logList?.length) {
|
|
280
249
|
if (logListHandler)
|
|
@@ -303,24 +272,44 @@ export default class LambderCaller {
|
|
|
303
272
|
const data = outcome.response;
|
|
304
273
|
await fetchEnded(data);
|
|
305
274
|
if (!outcome.ok && outcome.reason === 'versionExpired') {
|
|
306
|
-
// A
|
|
307
|
-
//
|
|
308
|
-
// same bundle back, and reloading again
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
|
|
312
|
-
|
|
275
|
+
// A page asks for one reload at a time, however many of its
|
|
276
|
+
// calls are refused. A call refused again after a reload
|
|
277
|
+
// means it brought the same bundle back, and reloading again
|
|
278
|
+
// would loop: the failure is reported instead of calling the
|
|
279
|
+
// handler. The outcome says versionExpired either way.
|
|
280
|
+
const decision = pageReloadLoopBreaker.recordVersionExpired(apiName, signature ?? "", version ?? "");
|
|
281
|
+
if (decision === "alreadyAsked")
|
|
282
|
+
return outcome;
|
|
283
|
+
if (decision === "loopConfirmed") {
|
|
284
|
+
await reportError(new Error(`Version expired again for API "${apiName}" within ${RELOAD_LOOP_WINDOW_MS / 60000} minutes of a reload: the bundle being served is still the stale one, so versionExpiredHandler was not called again.`));
|
|
313
285
|
return outcome;
|
|
314
286
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
287
|
+
await pageReloadLoopBreaker.runReloadAsk(async () => {
|
|
288
|
+
if (versionExpiredHandler) {
|
|
289
|
+
await versionExpiredHandler();
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
await reportError(new Error("Version Expired; Please refresh;"));
|
|
293
|
+
}
|
|
294
|
+
});
|
|
321
295
|
return outcome;
|
|
322
296
|
}
|
|
323
297
|
if (!outcome.ok && outcome.reason === 'sessionExpired') {
|
|
298
|
+
// Only when the session this answer is about is still the one
|
|
299
|
+
// the page holds. A call sent before a login or a rotation (a
|
|
300
|
+
// poll, another tab) can answer sessionExpired after it, and
|
|
301
|
+
// acting on it would delete the CSRF cookie the login just set
|
|
302
|
+
// and sign the person out again. A stale answer is returned
|
|
303
|
+
// with nothing touched. A cookie that is gone is no newer
|
|
304
|
+
// session: the server clears both cookies when it refuses an
|
|
305
|
+
// ambiguous pair, and a logout in another tab clears it too.
|
|
306
|
+
// A transport that keeps its own cookies (a jar) names the
|
|
307
|
+
// token it posted and the one it holds, since the page's
|
|
308
|
+
// cookie is not where that session lives.
|
|
309
|
+
const postedToken = answer.csrfTokens?.posted ?? token;
|
|
310
|
+
const heldToken = answer.csrfTokens ? answer.csrfTokens.held() : Cookies.get(this.sessionCsrfCookieKey) || "";
|
|
311
|
+
if (heldToken !== "" && heldToken !== postedToken)
|
|
312
|
+
return outcome;
|
|
324
313
|
this.clearSessionCookies();
|
|
325
314
|
if (sessionExpiredHandler) {
|
|
326
315
|
await sessionExpiredHandler();
|
|
@@ -345,8 +334,8 @@ export default class LambderCaller {
|
|
|
345
334
|
await messageHandler(data.message);
|
|
346
335
|
}
|
|
347
336
|
if (!outcome.ok && outcome.reason === 'errorMessage') {
|
|
348
|
-
if (errorMessageHandler &&
|
|
349
|
-
await errorMessageHandler(
|
|
337
|
+
if (errorMessageHandler && outcome.errorMessage !== undefined) {
|
|
338
|
+
await errorMessageHandler(outcome.errorMessage);
|
|
350
339
|
}
|
|
351
340
|
return outcome;
|
|
352
341
|
}
|
|
@@ -364,6 +353,10 @@ export default class LambderCaller {
|
|
|
364
353
|
return { ok: false, reason: 'unknown', error: wrappedError };
|
|
365
354
|
}
|
|
366
355
|
finally {
|
|
356
|
+
// Whatever ended the call before its attempt was told (a provider
|
|
357
|
+
// or a handler that threw): nothing sent tried nothing, and a
|
|
358
|
+
// request that left may have run.
|
|
359
|
+
idempotentAttempt.settle(sent ? { ok: false, reason: 'unknown' } : IDEMPOTENT_ATTEMPT_NOT_SENT);
|
|
367
360
|
dropFetchTracker();
|
|
368
361
|
abort.detach();
|
|
369
362
|
}
|
|
@@ -1,38 +1,68 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Stops a stale client from reloading forever.
|
|
3
3
|
*
|
|
4
|
-
* A versionExpired answer means
|
|
5
|
-
* not the one the server holds
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* cached bundle, a server deploy that failed behind
|
|
9
|
-
* reload brings back the same
|
|
10
|
-
* and the page reloads again, indefinitely.
|
|
4
|
+
* A versionExpired answer means this client's signature for the endpoint is
|
|
5
|
+
* not the one the server holds (or its version is below the server's floor),
|
|
6
|
+
* and the ordinary response is to reload for the current bundle. When the
|
|
7
|
+
* served bundle is itself stale (a frontend deployed with a signature map the
|
|
8
|
+
* server does not match, a cached bundle, a server deploy that failed behind
|
|
9
|
+
* a fresh frontend), the reload brings back the same bundle, the calls fail
|
|
10
|
+
* the same way, and the page reloads again, indefinitely.
|
|
11
11
|
*
|
|
12
|
-
* The evidence
|
|
13
|
-
* the same
|
|
14
|
-
* changed the endpoint would carry a different signature
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
12
|
+
* The evidence is a versionExpired for a call an earlier load refused within
|
|
13
|
+
* the window: the same endpoint with the same signature and version. A bundle
|
|
14
|
+
* that had changed the endpoint would carry a different signature, and a
|
|
15
|
+
* rebuilt one a different version. Each call is kept with the time it was
|
|
16
|
+
* refused, and only one refused before this document loaded counts: a call
|
|
17
|
+
* this page refused itself (a retry, another caller's) has seen no reload
|
|
18
|
+
* since. Every call refused within the window is kept, not only the latest,
|
|
19
|
+
* because a stale bundle is usually stale for several endpoints and they
|
|
20
|
+
* answer in no fixed order: whichever of them answers first on the next load
|
|
21
|
+
* has to find itself in the record.
|
|
18
22
|
*
|
|
19
|
-
*
|
|
20
|
-
* the
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
23
|
+
* A page asks one versionExpiredHandler at a time. While it runs, the rest of
|
|
24
|
+
* what the page hears (the other stale endpoints it boots with, a retry,
|
|
25
|
+
* another caller's) is recorded and answered quietly, since the page is being
|
|
26
|
+
* asked already. A handler that has returned while the page is still here may
|
|
27
|
+
* not have reloaded it (a per-call handler that does something else, a reload
|
|
28
|
+
* cancelled at a beforeunload prompt), so the next versionExpired asks again;
|
|
29
|
+
* where it did, asking again before the page unloads only repeats the reload
|
|
30
|
+
* under way. An instance is one page: LambderCaller keeps one at module
|
|
31
|
+
* scope, shared by every caller the page builds (in a runtime with no page,
|
|
32
|
+
* every caller of the process).
|
|
33
|
+
*
|
|
34
|
+
* Once a repeat is confirmed, every versionExpired within the window counts
|
|
35
|
+
* as one, whichever endpoint it names, so an endpoint the stale bundle calls
|
|
36
|
+
* only later does not earn a reload of its own. The window runs from the
|
|
37
|
+
* first versionExpired recorded, not from the latest. After it a reload is
|
|
38
|
+
* allowed again, so a client stuck on a stale bundle retries a few times an
|
|
39
|
+
* hour and recovers once the deploy is fixed.
|
|
40
|
+
*
|
|
41
|
+
* The record has to outlive the reload it watches for, so it lives in
|
|
42
|
+
* sessionStorage, per tab and per origin. Where that does not work (storage
|
|
43
|
+
* blocked, a runtime with no page) nothing outlives the page: it still asks
|
|
44
|
+
* one handler at a time, and a stale bundle there reloads as it would without
|
|
45
|
+
* this class.
|
|
25
46
|
*/
|
|
26
47
|
/** How long after the first versionExpired a repeat counts as the same loop. */
|
|
27
48
|
export declare const RELOAD_LOOP_WINDOW_MS: number;
|
|
49
|
+
/**
|
|
50
|
+
* What the caller does about a versionExpired: ask for a reload (the
|
|
51
|
+
* versionExpiredHandler, through runReloadAsk), nothing because the page's
|
|
52
|
+
* ask is still running, or report a confirmed loop instead of asking again.
|
|
53
|
+
*/
|
|
54
|
+
type ReloadLoopDecision = "askForReload" | "alreadyAsked" | "loopConfirmed";
|
|
28
55
|
export declare class LambderReloadLoopBreaker {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
56
|
+
private readonly loadedAt;
|
|
57
|
+
/** Whether the handler this page asked is still running: what the page hears meanwhile stays quiet. */
|
|
58
|
+
private reloadAskPending;
|
|
59
|
+
/** loadedAt: when this document loaded. A call refused before it was refused by an earlier load, with a reload in between. */
|
|
60
|
+
constructor(loadedAt?: number);
|
|
61
|
+
/** Records this versionExpired and decides what the caller does about it. */
|
|
62
|
+
recordVersionExpired(apiName: string, signature: string, version: string, now?: number): ReloadLoopDecision;
|
|
63
|
+
/** Runs the ask an askForReload decision calls for; until it settles, every versionExpired is alreadyAsked. */
|
|
64
|
+
runReloadAsk(ask: () => unknown): Promise<void>;
|
|
36
65
|
private read;
|
|
37
66
|
private write;
|
|
38
67
|
}
|
|
68
|
+
export {};
|