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.
Files changed (209) hide show
  1. package/CHANGELOG.md +1021 -3
  2. package/README.md +43 -21
  3. package/dist/api/LambderApiAnswer.d.ts +18 -22
  4. package/dist/api/LambderApiAnswer.js +6 -7
  5. package/dist/api/LambderApiCallContext.d.ts +21 -8
  6. package/dist/api/LambderApiCallContext.js +22 -4
  7. package/dist/api/LambderApiDefinition.d.ts +4 -3
  8. package/dist/api/LambderApiEnvelope.d.ts +14 -9
  9. package/dist/api/LambderApiEnvelope.js +33 -34
  10. package/dist/api/LambderApiGuards.d.ts +78 -51
  11. package/dist/api/LambderApiGuards.js +34 -36
  12. package/dist/api/LambderApiIdempotency.d.ts +74 -61
  13. package/dist/api/LambderApiIdempotency.js +226 -151
  14. package/dist/api/LambderApiOutputValidationError.d.ts +32 -0
  15. package/dist/api/LambderApiOutputValidationError.js +50 -0
  16. package/dist/api/LambderApiPipeline.d.ts +77 -39
  17. package/dist/api/LambderApiPipeline.js +135 -62
  18. package/dist/api/LambderApiRateLimits.d.ts +208 -54
  19. package/dist/api/LambderApiRateLimits.js +197 -108
  20. package/dist/api/LambderApiRequest.d.ts +27 -21
  21. package/dist/api/LambderApiRequest.js +26 -19
  22. package/dist/api/LambderApiSignature.d.ts +12 -15
  23. package/dist/api/LambderApiSignature.js +28 -51
  24. package/dist/api/LambderApiValidationRefusal.d.ts +9 -9
  25. package/dist/api/LambderApiValidationRefusal.js +10 -10
  26. package/dist/build/freshProcessVerifier.d.ts +13 -0
  27. package/dist/build/freshProcessVerifier.js +19 -0
  28. package/dist/build/writeApiSignatures.d.ts +109 -0
  29. package/dist/build/writeApiSignatures.js +222 -0
  30. package/dist/build.d.ts +9 -0
  31. package/dist/build.js +8 -0
  32. package/dist/client/LambderCaller.d.ts +13 -44
  33. package/dist/client/LambderCaller.js +77 -84
  34. package/dist/client/LambderReloadLoopBreaker.d.ts +56 -26
  35. package/dist/client/LambderReloadLoopBreaker.js +90 -46
  36. package/dist/client/lambderFetchTransport.d.ts +4 -1
  37. package/dist/client/lambderFetchTransport.js +52 -28
  38. package/dist/client.d.ts +5 -3
  39. package/dist/client.js +2 -1
  40. package/dist/core/Lambder.d.ts +161 -69
  41. package/dist/core/Lambder.js +370 -226
  42. package/dist/core/LambderContext.d.ts +82 -15
  43. package/dist/core/LambderContext.js +107 -20
  44. package/dist/core/LambderCors.d.ts +21 -3
  45. package/dist/core/LambderCors.js +35 -16
  46. package/dist/core/LambderCrashHandling.d.ts +40 -0
  47. package/dist/core/LambderCrashHandling.js +97 -0
  48. package/dist/core/LambderCreateOptions.d.ts +151 -75
  49. package/dist/core/LambderCreateOptions.js +16 -23
  50. package/dist/core/LambderFiles.d.ts +28 -7
  51. package/dist/core/LambderFiles.js +73 -33
  52. package/dist/core/LambderIndexHtml.js +12 -11
  53. package/dist/core/LambderPolicyBuilders.d.ts +17 -5
  54. package/dist/core/LambderPolicyBuilders.js +17 -5
  55. package/dist/core/LambderPublicFiles.d.ts +11 -5
  56. package/dist/core/LambderPublicFiles.js +32 -4
  57. package/dist/core/LambderRequestPath.d.ts +43 -0
  58. package/dist/core/LambderRequestPath.js +63 -0
  59. package/dist/core/LambderResponse.d.ts +26 -5
  60. package/dist/core/LambderResponse.js +157 -70
  61. package/dist/core/LambderResponseBuilder.d.ts +49 -4
  62. package/dist/core/LambderResponseBuilder.js +64 -3
  63. package/dist/core/LambderRouting.d.ts +2 -3
  64. package/dist/core/LambderRouting.js +22 -7
  65. package/dist/core/LambderTemplatingEngine.js +211 -32
  66. package/dist/index.d.ts +15 -8
  67. package/dist/index.js +5 -4
  68. package/dist/invoke/LambderInvokeCaller.d.ts +37 -42
  69. package/dist/invoke/LambderInvokeCaller.js +76 -66
  70. package/dist/invoke/LambderInvokeOutcome.d.ts +27 -26
  71. package/dist/invoke/LambderInvokeOutcome.js +9 -22
  72. package/dist/invoke/LambderLambdaEvent.d.ts +44 -10
  73. package/dist/invoke/LambderLambdaEvent.js +80 -37
  74. package/dist/invoke/lambderHandlerTransport.d.ts +12 -10
  75. package/dist/invoke/lambderHandlerTransport.js +16 -19
  76. package/dist/mock/LambderMockApp.d.ts +67 -83
  77. package/dist/mock/LambderMockApp.js +167 -153
  78. package/dist/mock/LambderMockBrowserCookies.d.ts +24 -28
  79. package/dist/mock/LambderMockBrowserCookies.js +24 -28
  80. package/dist/mock/LambderMockCallRecorder.d.ts +15 -22
  81. package/dist/mock/LambderMockCallRecorder.js +19 -28
  82. package/dist/mock/LambderMockCreateOptions.d.ts +42 -24
  83. package/dist/mock/LambderMockEntryRegistry.d.ts +11 -12
  84. package/dist/mock/LambderMockEntryRegistry.js +24 -29
  85. package/dist/mock/LambderMockFailureInjector.d.ts +3 -6
  86. package/dist/mock/LambderMockFailureInjector.js +3 -6
  87. package/dist/mock/LambderMockTypes.d.ts +78 -108
  88. package/dist/mock/lambderMockInvokeTransport.d.ts +11 -13
  89. package/dist/mock/lambderMockInvokeTransport.js +11 -10
  90. package/dist/mock/lambderMockMswHandler.d.ts +33 -29
  91. package/dist/mock/lambderMockMswHandler.js +50 -39
  92. package/dist/mock.d.ts +3 -1
  93. package/dist/mock.js +5 -3
  94. package/dist/session/LambderSessionController.d.ts +108 -89
  95. package/dist/session/LambderSessionController.js +187 -168
  96. package/dist/session/LambderSessionCrypto.d.ts +16 -7
  97. package/dist/session/LambderSessionCrypto.js +26 -12
  98. package/dist/session/LambderSessionManager.d.ts +136 -47
  99. package/dist/session/LambderSessionManager.js +280 -139
  100. package/dist/shared/LambderHtml.d.ts +42 -3
  101. package/dist/shared/LambderHtml.js +127 -7
  102. package/dist/shared/LambderHtmlPositions.d.ts +173 -0
  103. package/dist/shared/LambderHtmlPositions.js +652 -0
  104. package/dist/shared/LambderI18n.d.ts +10 -11
  105. package/dist/shared/LambderI18n.js +33 -21
  106. package/dist/shared/contracts/LambderCache.d.ts +66 -0
  107. package/dist/shared/contracts/LambderCache.js +11 -0
  108. package/dist/shared/contracts/LambderFileSource.d.ts +6 -6
  109. package/dist/shared/contracts/LambderFileSource.js +5 -8
  110. package/dist/shared/contracts/LambderIdempotencyStore.d.ts +51 -22
  111. package/dist/shared/contracts/LambderIdempotencyStore.js +4 -5
  112. package/dist/shared/contracts/LambderRateLimiter.d.ts +27 -15
  113. package/dist/shared/contracts/LambderRateLimiter.js +4 -5
  114. package/dist/shared/contracts/LambderSessionStore.d.ts +65 -26
  115. package/dist/shared/contracts/LambderSessionStore.js +5 -6
  116. package/dist/shared/transport/LambderApiTransport.d.ts +27 -27
  117. package/dist/shared/transport/LambderApiTransport.js +7 -7
  118. package/dist/shared/transport/LambderCookieJar.d.ts +28 -35
  119. package/dist/shared/transport/LambderCookieJar.js +54 -66
  120. package/dist/shared/transport/lambderCookieJarTransport.d.ts +11 -13
  121. package/dist/shared/transport/lambderCookieJarTransport.js +24 -23
  122. package/dist/shared/util/LambderCallAbort.d.ts +5 -5
  123. package/dist/shared/util/LambderCallAbort.js +5 -5
  124. package/dist/shared/util/LambderClientIp.d.ts +27 -11
  125. package/dist/shared/util/LambderClientIp.js +96 -13
  126. package/dist/shared/util/LambderExpiringMap.d.ts +35 -49
  127. package/dist/shared/util/LambderExpiringMap.js +41 -57
  128. package/dist/shared/util/LambderNodeModules.js +6 -7
  129. package/dist/shared/util/LambderOptionChecks.d.ts +4 -4
  130. package/dist/shared/util/LambderOptionChecks.js +4 -4
  131. package/dist/shared/util/LambderResponseBrand.d.ts +5 -5
  132. package/dist/shared/util/LambderResponseBrand.js +5 -5
  133. package/dist/shared/util/LambderTestingDoors.d.ts +29 -0
  134. package/dist/shared/util/LambderTestingDoors.js +29 -0
  135. package/dist/shared/util/LambderTypeUtilities.d.ts +7 -8
  136. package/dist/shared/util/LambderTypeUtilities.js +3 -3
  137. package/dist/shared/util/boundKeyField.d.ts +20 -0
  138. package/dist/shared/util/boundKeyField.js +34 -0
  139. package/dist/shared/util/canonicalJson.d.ts +11 -0
  140. package/dist/shared/util/canonicalJson.js +28 -0
  141. package/dist/shared/util/joinKeyFields.d.ts +20 -0
  142. package/dist/shared/util/joinKeyFields.js +22 -0
  143. package/dist/shared/wire/LambderAnswerHeaders.d.ts +12 -16
  144. package/dist/shared/wire/LambderAnswerHeaders.js +12 -16
  145. package/dist/shared/wire/LambderApiContract.d.ts +107 -32
  146. package/dist/shared/wire/LambderApiOutcome.d.ts +43 -31
  147. package/dist/shared/wire/LambderApiOutcome.js +48 -23
  148. package/dist/shared/wire/LambderApiRefusal.d.ts +39 -27
  149. package/dist/shared/wire/LambderApiRefusal.js +36 -7
  150. package/dist/shared/wire/LambderApiSignature.d.ts +18 -22
  151. package/dist/shared/wire/LambderApiSignature.js +16 -19
  152. package/dist/shared/wire/LambderCallOptions.d.ts +38 -47
  153. package/dist/shared/wire/LambderCallOptions.js +9 -11
  154. package/dist/shared/wire/LambderCompressionCodec.d.ts +29 -34
  155. package/dist/shared/wire/LambderCompressionCodec.js +31 -36
  156. package/dist/shared/wire/LambderCompressionOption.d.ts +9 -9
  157. package/dist/shared/wire/LambderCompressionOption.js +9 -9
  158. package/dist/shared/wire/LambderCrashDetail.d.ts +12 -15
  159. package/dist/shared/wire/LambderCrashDetail.js +12 -15
  160. package/dist/shared/wire/LambderDefaultApiPath.d.ts +6 -0
  161. package/dist/shared/wire/LambderDefaultApiPath.js +6 -0
  162. package/dist/shared/wire/LambderHttpStatus.d.ts +6 -7
  163. package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +89 -0
  164. package/dist/shared/wire/LambderIdempotencyKeyScope.js +146 -0
  165. package/dist/shared/wire/LambderInvokeApiId.d.ts +27 -0
  166. package/dist/shared/wire/LambderInvokeApiId.js +27 -0
  167. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +79 -0
  168. package/dist/shared/wire/LambderOutcomeAssertions.js +112 -0
  169. package/dist/shared/wire/LambderRequestPayload.d.ts +18 -20
  170. package/dist/shared/wire/LambderRequestPayload.js +4 -6
  171. package/dist/stores/LambderCacheFiller.d.ts +48 -0
  172. package/dist/stores/LambderCacheFiller.js +119 -0
  173. package/dist/stores/LambderCacheKeys.d.ts +26 -0
  174. package/dist/stores/LambderCacheKeys.js +54 -0
  175. package/dist/stores/LambderCacheValues.d.ts +45 -0
  176. package/dist/stores/LambderCacheValues.js +74 -0
  177. package/dist/stores/LambderDdbCache.d.ts +121 -56
  178. package/dist/stores/LambderDdbCache.js +528 -225
  179. package/dist/stores/LambderDdbIdempotencyStore.d.ts +33 -22
  180. package/dist/stores/LambderDdbIdempotencyStore.js +75 -50
  181. package/dist/stores/LambderDdbRateLimiter.d.ts +76 -20
  182. package/dist/stores/LambderDdbRateLimiter.js +151 -39
  183. package/dist/stores/LambderDdbSdk.d.ts +43 -31
  184. package/dist/stores/LambderDdbSdk.js +79 -33
  185. package/dist/stores/LambderDdbSessionStore.d.ts +27 -14
  186. package/dist/stores/LambderDdbSessionStore.js +119 -47
  187. package/dist/stores/LambderHttpFileSource.d.ts +15 -6
  188. package/dist/stores/LambderHttpFileSource.js +15 -13
  189. package/dist/stores/LambderMemoryCache.d.ts +49 -0
  190. package/dist/stores/LambderMemoryCache.js +113 -0
  191. package/dist/stores/LambderMemoryIdempotencyStore.d.ts +13 -12
  192. package/dist/stores/LambderMemoryIdempotencyStore.js +31 -30
  193. package/dist/stores/LambderMemoryRateLimiter.d.ts +8 -9
  194. package/dist/stores/LambderMemoryRateLimiter.js +14 -13
  195. package/dist/stores/LambderMemorySessionStore.d.ts +14 -11
  196. package/dist/stores/LambderMemorySessionStore.js +38 -19
  197. package/dist/stores/LambderS3FileSource.d.ts +21 -6
  198. package/dist/stores/LambderS3FileSource.js +12 -7
  199. package/dist/testing/LambderTestApp.d.ts +176 -0
  200. package/dist/testing/LambderTestApp.js +204 -0
  201. package/dist/testing/LambderTestVisitor.d.ts +153 -0
  202. package/dist/testing/LambderTestVisitor.js +154 -0
  203. package/dist/testing.d.ts +27 -0
  204. package/dist/testing.js +24 -0
  205. package/package.json +20 -3
  206. package/dist/api/LambderApiPolicyEngine.d.ts +0 -36
  207. package/dist/api/LambderApiPolicyEngine.js +0 -77
  208. package/dist/shared/util/LambderKeyFields.d.ts +0 -32
  209. 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 is the in-flight list rather than a log of every call ever made. */
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?: MessageHandler;
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
- isCorsEnabled: boolean;
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?: MessageHandler;
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, so it cannot drift from the list the way a separate flag did. */
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, so it cannot drift from the list the way a separate flag did. */
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: this.isCorsEnabled });
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
- // Dropped the moment the call settles, and idempotently, since the
157
- // finally block below runs for the paths fetchEnded never reaches.
158
- // Left in, the list grew by one per call forever, and every handler
159
- // call scanned all of it: a long-lived page paid more per call the
160
- // longer it had been open.
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
- ...(options?.idempotencyKey !== undefined ? { idempotencyKey: options.idempotencyKey } : {}),
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: the 500 whose global error handler attached a crash and
275
- // a logList is the answer whose log trail is worth the most, and
276
- // surfacing them under the envelope reads meant it was the one
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 repeat of a recent versionExpired for the same endpoint and
307
- // signature means the reload the handler performed brought the
308
- // same bundle back, and reloading again would loop. The
309
- // handler is not called; the failure is reported instead, and
310
- // the outcome still says versionExpired.
311
- if (this.reloadLoopBreaker.isRepeat(apiName, signature ?? "")) {
312
- await reportError(new Error(`Version expired again for API "${apiName}" within ${RELOAD_LOOP_WINDOW_MS / 60000} minutes with the same signature: the bundle being served is still the stale one, so versionExpiredHandler was not called again.`));
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
- if (versionExpiredHandler) {
316
- await versionExpiredHandler();
317
- }
318
- else {
319
- await reportError(new Error("Version Expired; Please refresh;"));
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 && data.errorMessage !== undefined) {
349
- await errorMessageHandler(data.errorMessage);
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 "this client's signature for the endpoint is
5
- * not the one the server holds", and the ordinary response is to reload and
6
- * get the current bundle. When the bundle being served is itself the stale
7
- * one (a frontend deployed with a signature map the server does not match, a
8
- * cached bundle, a server deploy that failed behind a fresh frontend), the
9
- * reload brings back the same signature, the same call fails the same way,
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 of that loop is a versionExpired for the same endpoint with
13
- * the same signature shortly after the last one: a bundle that had actually
14
- * changed the endpoint would carry a different signature. The record lives
15
- * in sessionStorage, which is per tab and survives a reload, so the new page
16
- * instance sees what the previous one saw; without sessionStorage (a test, a
17
- * non-browser runtime) an in-memory record does the same within one page.
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
- * Once a repeat is confirmed, every versionExpired within the window from
20
- * the first one counts as a repeat too, whichever endpoint it names: a stale
21
- * bundle is usually stale for several endpoints, and one reload per endpoint
22
- * is still a loop, only a slower one. After the window a reload is allowed
23
- * again, so a client stuck on a stale bundle retries a few times an hour and
24
- * recovers by itself once the deploy is fixed.
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
- /** The record when sessionStorage is unavailable; sessionStorage is read first wherever it exists. */
30
- private memory;
31
- /**
32
- * Records this versionExpired and says whether it repeats a recent one,
33
- * in which case the caller must not invoke versionExpiredHandler again.
34
- */
35
- isRepeat(apiName: string, signature: string, now?: number): boolean;
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 {};