lambder 14.1.1 → 15.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/CHANGELOG.md +429 -0
  2. package/README.md +14 -17
  3. package/dist/api/LambderApiDeclarations.d.ts +11 -5
  4. package/dist/api/LambderApiDefinition.d.ts +2 -1
  5. package/dist/api/LambderApiEnvelope.d.ts +2 -2
  6. package/dist/api/LambderApiEnvelope.js +8 -2
  7. package/dist/api/LambderApiGuards.d.ts +44 -5
  8. package/dist/api/LambderApiGuards.js +27 -0
  9. package/dist/api/LambderApiIdempotency.d.ts +14 -7
  10. package/dist/api/LambderApiIdempotency.js +39 -27
  11. package/dist/api/LambderApiPipeline.d.ts +25 -11
  12. package/dist/api/LambderApiPipeline.js +34 -17
  13. package/dist/api/LambderApiRateLimits.d.ts +28 -21
  14. package/dist/api/LambderApiRateLimits.js +55 -35
  15. package/dist/api/LambderApiRefusals.d.ts +10 -4
  16. package/dist/api/LambderApiRefusals.js +31 -5
  17. package/dist/api/LambderApiRequest.d.ts +21 -1
  18. package/dist/api/LambderApiRequest.js +31 -2
  19. package/dist/build/checkApiRefusals.d.ts +8 -3
  20. package/dist/build/checkApiRefusals.js +80 -29
  21. package/dist/build/contractPrinterProcess.d.ts +11 -0
  22. package/dist/build/contractPrinterProcess.js +17 -0
  23. package/dist/build/generateApiFiles.d.ts +26 -5
  24. package/dist/build/generateApiFiles.js +68 -7
  25. package/dist/build/writeApiSchemas.d.ts +109 -0
  26. package/dist/build/writeApiSchemas.js +308 -0
  27. package/dist/build.d.ts +3 -1
  28. package/dist/build.js +2 -0
  29. package/dist/client/LambderCaller.d.ts +25 -11
  30. package/dist/client/LambderCaller.js +57 -49
  31. package/dist/client/LambderUploadRunner.d.ts +55 -27
  32. package/dist/client/LambderUploadRunner.js +89 -34
  33. package/dist/client.d.ts +6 -6
  34. package/dist/client.js +2 -2
  35. package/dist/core/Lambder.d.ts +31 -21
  36. package/dist/core/Lambder.js +79 -23
  37. package/dist/core/LambderCallSummary.d.ts +1 -1
  38. package/dist/core/LambderContext.d.ts +59 -7
  39. package/dist/core/LambderContext.js +31 -15
  40. package/dist/core/LambderCrashHandling.d.ts +5 -0
  41. package/dist/core/LambderCrashHandling.js +11 -1
  42. package/dist/core/LambderCreateOptions.d.ts +28 -17
  43. package/dist/core/LambderCreateOptions.js +14 -7
  44. package/dist/core/LambderResolver.d.ts +3 -2
  45. package/dist/core/LambderResponseBuilder.d.ts +20 -9
  46. package/dist/core/LambderResponseBuilder.js +39 -8
  47. package/dist/core/LambderTemplatingEngine.d.ts +24 -6
  48. package/dist/core/LambderTemplatingEngine.js +23 -3
  49. package/dist/index.d.ts +15 -12
  50. package/dist/index.js +7 -5
  51. package/dist/invoke/LambderInvokeCaller.d.ts +25 -4
  52. package/dist/invoke/LambderInvokeCaller.js +47 -9
  53. package/dist/invoke/LambderInvokeOutcome.d.ts +7 -6
  54. package/dist/mock/LambderMockAdapterDoor.d.ts +40 -0
  55. package/dist/mock/LambderMockAdapterDoor.js +10 -0
  56. package/dist/mock/LambderMockApiSchemas.d.ts +28 -0
  57. package/dist/mock/LambderMockApiSchemas.js +150 -0
  58. package/dist/mock/LambderMockApp.d.ts +56 -48
  59. package/dist/mock/LambderMockApp.js +107 -62
  60. package/dist/mock/LambderMockBrowserCookies.js +18 -5
  61. package/dist/mock/LambderMockCreateOptions.d.ts +51 -4
  62. package/dist/mock/LambderMockFailureInjector.d.ts +12 -3
  63. package/dist/mock/LambderMockFailureInjector.js +12 -8
  64. package/dist/mock/LambderMockTypes.d.ts +8 -4
  65. package/dist/mock/lambderMockInvokeTransport.js +2 -9
  66. package/dist/mock/lambderMockMswHandler.d.ts +19 -13
  67. package/dist/mock/lambderMockMswHandler.js +25 -22
  68. package/dist/mock.d.ts +1 -0
  69. package/dist/secrets/LambderOneShotSecrets.d.ts +1 -1
  70. package/dist/secrets/LambderOneShotSecrets.js +6 -36
  71. package/dist/secrets/LambderPasswordHasher.d.ts +56 -0
  72. package/dist/secrets/LambderPasswordHasher.js +168 -0
  73. package/dist/session/LambderSessionController.d.ts +37 -4
  74. package/dist/session/LambderSessionController.js +36 -6
  75. package/dist/session/LambderSessionManager.d.ts +33 -3
  76. package/dist/session/LambderSessionManager.js +55 -16
  77. package/dist/shared/LambderHtml.d.ts +23 -4
  78. package/dist/shared/LambderHtml.js +17 -5
  79. package/dist/shared/LambderI18n.d.ts +106 -16
  80. package/dist/shared/LambderI18n.js +259 -74
  81. package/dist/shared/contracts/LambderCache.d.ts +14 -1
  82. package/dist/shared/contracts/LambderRateLimiter.d.ts +5 -6
  83. package/dist/shared/transport/LambderApiTransport.d.ts +3 -2
  84. package/dist/shared/util/LambderBackoffTimer.d.ts +24 -13
  85. package/dist/shared/util/LambderBackoffTimer.js +40 -12
  86. package/dist/shared/util/LambderCallAbort.d.ts +22 -6
  87. package/dist/shared/util/LambderCallAbort.js +37 -9
  88. package/dist/shared/util/LambderErrorReportMark.d.ts +4 -0
  89. package/dist/shared/util/LambderErrorReportMark.js +22 -0
  90. package/dist/shared/util/LambderKeyFieldDigest.d.ts +46 -0
  91. package/dist/shared/util/LambderKeyFieldDigest.js +75 -0
  92. package/dist/shared/util/LambderSignedClaims.d.ts +19 -0
  93. package/dist/shared/util/LambderSignedClaims.js +54 -0
  94. package/dist/shared/util/LambderSwappableInstances.d.ts +29 -0
  95. package/dist/shared/util/LambderSwappableInstances.js +54 -0
  96. package/dist/shared/util/LambderTestingDoors.d.ts +6 -2
  97. package/dist/shared/util/LambderTestingDoors.js +6 -2
  98. package/dist/shared/util/LambderTextDigest.d.ts +5 -5
  99. package/dist/shared/util/LambderTextDigest.js +5 -5
  100. package/dist/shared/wire/LambderApiContract.d.ts +1 -1
  101. package/dist/shared/wire/LambderApiOptionEntries.d.ts +9 -1
  102. package/dist/shared/wire/LambderApiOptionEntries.js +15 -0
  103. package/dist/shared/wire/LambderApiOptionValues.d.ts +3 -2
  104. package/dist/shared/wire/LambderApiOutcome.d.ts +37 -19
  105. package/dist/shared/wire/LambderApiOutcome.js +1 -1
  106. package/dist/shared/wire/LambderApiRefusal.d.ts +39 -12
  107. package/dist/shared/wire/LambderApiRefusal.js +3 -3
  108. package/dist/shared/wire/LambderApiSchemaEntries.d.ts +21 -0
  109. package/dist/shared/wire/LambderApiSchemaEntries.js +18 -0
  110. package/dist/shared/wire/LambderCallOptions.d.ts +3 -3
  111. package/dist/shared/wire/LambderCallOutcome.d.ts +2 -1
  112. package/dist/shared/wire/LambderCallOutcome.js +3 -2
  113. package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +5 -3
  114. package/dist/shared/wire/LambderIdempotencyKeyScope.js +5 -3
  115. package/dist/shared/wire/LambderInvokeApiId.d.ts +3 -3
  116. package/dist/shared/wire/LambderInvokeApiId.js +3 -3
  117. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +7 -6
  118. package/dist/shared/wire/LambderOutcomeAssertions.js +1 -1
  119. package/dist/stores/LambderCacheValues.d.ts +2 -5
  120. package/dist/stores/LambderDdbCache.d.ts +15 -9
  121. package/dist/stores/LambderDdbCache.js +25 -6
  122. package/dist/stores/LambderDdbIdempotencyStore.d.ts +6 -4
  123. package/dist/stores/LambderDdbIdempotencyStore.js +32 -52
  124. package/dist/stores/LambderDdbOneShotSecretStore.d.ts +11 -0
  125. package/dist/stores/LambderDdbOneShotSecretStore.js +17 -5
  126. package/dist/stores/LambderDdbSdk.d.ts +59 -29
  127. package/dist/stores/LambderDdbSdk.js +116 -44
  128. package/dist/stores/LambderDdbSessionStore.d.ts +26 -11
  129. package/dist/stores/LambderDdbSessionStore.js +133 -68
  130. package/dist/stores/LambderMemoryCache.d.ts +2 -2
  131. package/dist/stores/LambderS3FileSource.d.ts +3 -2
  132. package/dist/stores/LambderS3FileSource.js +11 -13
  133. package/dist/stores/LambderS3UploadBucket.d.ts +12 -0
  134. package/dist/stores/LambderS3UploadBucket.js +23 -0
  135. package/dist/stores/LambderStorageBackedCache.d.ts +2 -2
  136. package/dist/stores/LambderStoredText.d.ts +26 -0
  137. package/dist/stores/LambderStoredText.js +25 -0
  138. package/dist/testing/LambderConformanceRunner.d.ts +9 -2
  139. package/dist/testing/LambderConformanceRunner.js +9 -2
  140. package/dist/testing/LambderTestApp.d.ts +65 -7
  141. package/dist/testing/LambderTestApp.js +101 -6
  142. package/dist/testing/LambderTestVisitor.d.ts +43 -12
  143. package/dist/testing/LambderTestVisitor.js +35 -11
  144. package/dist/testing/bootLambdaPackage.d.ts +105 -0
  145. package/dist/testing/bootLambdaPackage.js +265 -0
  146. package/dist/testing/lambdaPackageBootProcess.d.ts +39 -0
  147. package/dist/testing/lambdaPackageBootProcess.js +130 -0
  148. package/dist/testing/lambderCacheConformance.d.ts +26 -0
  149. package/dist/testing/lambderCacheConformance.js +287 -0
  150. package/dist/testing/lambderCacheStorageConformance.d.ts +25 -0
  151. package/dist/testing/lambderCacheStorageConformance.js +159 -0
  152. package/dist/testing/lambderSessionStoreConformance.d.ts +3 -3
  153. package/dist/testing/lambderSessionStoreConformance.js +3 -3
  154. package/dist/testing.d.ts +7 -1
  155. package/dist/testing.js +5 -0
  156. package/package.json +1 -6
  157. package/dist/shared/util/boundKeyField.d.ts +0 -20
  158. package/dist/shared/util/boundKeyField.js +0 -34
@@ -1,7 +1,7 @@
1
1
  import type { LambderUncheckedRefusalMessage } from '../shared/wire/LambderApiRefusal.js';
2
2
  import { type LambderRequestCompressionOption } from '../shared/wire/LambderRequestPayload.js';
3
3
  import type { LambderApiContractShape, LambderContractAnyRefusalMessage, LambderContractRefusalMessage } from '../shared/wire/LambderApiContract.js';
4
- import { type LambderApiOutcome, type LambderValidationError } from '../shared/wire/LambderApiOutcome.js';
4
+ import { type LambderApiFailure, type LambderApiOutcome, type LambderValidationError } from '../shared/wire/LambderApiOutcome.js';
5
5
  import { type LambderCallArgs, type LambderContractOutputOf, type LambderGuardInputsProviderOption, type LambderSharedCallOptions } from '../shared/wire/LambderCallOptions.js';
6
6
  import { type LambderApiTransport } from '../shared/transport/LambderApiTransport.js';
7
7
  import { type LambderApiSignatureMap } from '../shared/wire/LambderApiSignatureMap.js';
@@ -23,12 +23,23 @@ type FetchStartEventHandler = (params: {
23
23
  fetchParams: EventHandlerFetchParams;
24
24
  activeFetchList: FetchTracker[];
25
25
  }) => void | Promise<void>;
26
- type FetchEndEventHandler = (params: {
26
+ /**
27
+ * Told that a call ended, however it ended, with the outcome apiOutcome()
28
+ * resolves to. TMessage is what the calls it hears can refuse with, as for
29
+ * RefusalHandler.
30
+ */
31
+ type FetchEndEventHandler<TMessage extends LambderUncheckedRefusalMessage = LambderUncheckedRefusalMessage> = (params: {
27
32
  fetchParams: EventHandlerFetchParams;
28
- fetchResult: any;
33
+ fetchResult: LambderApiOutcome<unknown, TMessage>;
29
34
  activeFetchList: FetchTracker[];
30
35
  }) => void | Promise<void>;
31
- type ErrorHandler = (err: Error) => void | Promise<void>;
36
+ /**
37
+ * Told a failure no other handler takes, with the error to report and the
38
+ * failure outcome it came from (its reason, status, refusal and response),
39
+ * so a reporter can word or file it by reason without reading the message.
40
+ * A call its own signal aborted is never one. TMessage as for RefusalHandler.
41
+ */
42
+ type ErrorHandler<TMessage extends LambderUncheckedRefusalMessage = LambderUncheckedRefusalMessage> = (error: Error, failure: LambderApiFailure<TMessage>) => void | Promise<void>;
32
43
  type ValidationErrorHandler = (zodError: LambderValidationError) => (void | false) | Promise<(void | false)>;
33
44
  /**
34
45
  * Handed the refusal as its message object, a plain-string refusal
@@ -51,13 +62,14 @@ export type LambderCallOptions<TMessage extends LambderUncheckedRefusalMessage =
51
62
  refusalHandler?: RefusalHandler<TMessage>;
52
63
  apiInputValidationErrorHandler?: ValidationErrorHandler;
53
64
  notAuthorizedHandler?: NotifyHandler;
54
- errorHandler?: ErrorHandler;
65
+ errorHandler?: ErrorHandler<TMessage>;
55
66
  logListHandler?: LambderLogListHandler;
56
67
  fetchStartedHandler?: FetchStartEventHandler;
57
- fetchEndedHandler?: FetchEndEventHandler;
68
+ fetchEndedHandler?: FetchEndEventHandler<TMessage>;
58
69
  };
59
70
  type LambderCallerBaseOptions<TMessage extends LambderUncheckedRefusalMessage = LambderUncheckedRefusalMessage> = {
60
- apiPath: string;
71
+ /** Must match the server's apiPath. Default: "/api", the server's own default. */
72
+ apiPath?: string;
61
73
  /** Sent with every call as `version`, informational: the server stamps its own on every answer. */
62
74
  apiVersion?: string;
63
75
  /**
@@ -82,11 +94,12 @@ type LambderCallerBaseOptions<TMessage extends LambderUncheckedRefusalMessage =
82
94
  /** Handed every refusal a call of this caller comes back with, typed with every code the contract declares. */
83
95
  refusalHandler?: RefusalHandler<TMessage>;
84
96
  notAuthorizedHandler?: NotifyHandler;
85
- errorHandler?: ErrorHandler;
97
+ /** Handed every failure no other handler takes (see ErrorHandler), typed with every code the contract declares. */
98
+ errorHandler?: ErrorHandler<TMessage>;
86
99
  /** Receives each answer's logList, with the API name. Default: console.log with a `[lambder]` prefix, one line per entry. */
87
100
  logListHandler?: LambderLogListHandler;
88
101
  fetchStartedHandler?: FetchStartEventHandler;
89
- fetchEndedHandler?: FetchEndEventHandler;
102
+ fetchEndedHandler?: FetchEndEventHandler<TMessage>;
90
103
  apiInputValidationErrorHandler?: ValidationErrorHandler;
91
104
  /** Must mirror the server's session cookie Domain, otherwise expired cookies cannot be cleared. */
92
105
  sessionCookieDomain?: string | ((hostname: string) => string | undefined | null);
@@ -143,8 +156,9 @@ declare class LambderCallerCore<TContract extends LambderApiContractShape = any,
143
156
  * The endpoint's output on success, `undefined` on every failure. An
144
157
  * output is always an object or an array, so the result is truthy exactly
145
158
  * when the call succeeded; the handlers configured on the caller have
146
- * already been told why it did not. Use apiOutcome() to branch on the
147
- * reason at the call site.
159
+ * already been told why it did not, unless the call's own signal aborted
160
+ * it, which tells none. Use apiOutcome() to branch on the reason at the
161
+ * call site.
148
162
  */
149
163
  api<TApiName extends keyof TContract & string = string>(apiName: TApiName, ...rest: LambderCallArgs<TContract, TApiName, TProvidedGuards, LambderCallOptions<LambderContractRefusalMessage<TContract, TApiName>>>): Promise<LambderContractOutputOf<TContract, TApiName> | undefined>;
150
164
  }
@@ -1,7 +1,7 @@
1
1
  import Cookies from 'js-cookie';
2
2
  import { compressPayloadGzip, isRequestCompressionAvailable, resolveRequestCompressionMinBytes, DEFAULT_REQUEST_COMPRESSION_SETTINGS, } from '../shared/wire/LambderRequestPayload.js';
3
3
  import { resolveCompressionOption } from '../shared/wire/LambderCompressionOption.js';
4
- import { resolveApiOutcome } from '../shared/wire/LambderApiOutcome.js';
4
+ import { resolveApiOutcome, } from '../shared/wire/LambderApiOutcome.js';
5
5
  import { mergeGuardInputs, } from '../shared/wire/LambderCallOptions.js';
6
6
  import { beginIdempotentAttempt, IDEMPOTENT_ATTEMPT_NOT_SENT } from '../shared/wire/LambderIdempotencyKeyScope.js';
7
7
  import { createCallAbort } from '../shared/util/LambderCallAbort.js';
@@ -13,6 +13,7 @@ import { LambderReloadLoopBreaker, RELOAD_LOOP_WINDOW_MS } from './LambderReload
13
13
  import { lambderFetchTransport } from './lambderFetchTransport.js';
14
14
  import { withApiGroupCalls } from '../shared/wire/LambderApiGroupCalls.js';
15
15
  import { splitApiName } from '../shared/wire/LambderApiNames.js';
16
+ import { DEFAULT_API_PATH } from '../shared/wire/LambderDefaultApiPath.js';
16
17
  /**
17
18
  * What keeps a stale bundle from reloading itself forever (see the class):
18
19
  * one for the page, shared by every caller it builds, since a reload is the
@@ -39,9 +40,10 @@ class LambderCallerCore {
39
40
  get isLoading() { return this.fetchTrackerList.length > 0; }
40
41
  #versionExpiredHandler;
41
42
  #sessionExpiredHandler;
42
- // Hears every endpoint's refusals, so held at the widest message: the
43
- // constructor option types it to the contract's codes, and dispatch,
44
- // generic over one endpoint's message, hands it one of those.
43
+ // These three hear every endpoint's calls, so they are held at the widest
44
+ // message: the constructor options type them to the contract's codes,
45
+ // and dispatch, generic over one endpoint's message, hands them one of
46
+ // those.
45
47
  #refusalHandler;
46
48
  #notAuthorizedHandler;
47
49
  #errorHandler;
@@ -59,7 +61,7 @@ class LambderCallerCore {
59
61
  // The conditional provider option is resolved per instantiation;
60
62
  // inside the class it is read through the plain shape.
61
63
  const { apiPath, apiVersion, apiSignatures, isCorsEnabled, timeoutMs, versionExpiredHandler, sessionExpiredHandler, refusalHandler, notAuthorizedHandler, errorHandler, logListHandler, fetchStartedHandler, fetchEndedHandler, apiInputValidationErrorHandler, sessionCookieDomain, requestCompression, guardInputsProvider, transport, } = options;
62
- this.#apiPath = apiPath;
64
+ this.#apiPath = apiPath ?? DEFAULT_API_PATH;
63
65
  this.#apiVersion = apiVersion;
64
66
  this.#apiSignatures = apiSignatures;
65
67
  this.#timeoutMs = timeoutMs;
@@ -137,38 +139,50 @@ class LambderCallerCore {
137
139
  this.fetchTrackerList.splice(at, 1);
138
140
  };
139
141
  let fetchEndCalled = false;
140
- const fetchEnded = async (fetchResult) => {
142
+ const fetchEnded = async (outcome) => {
141
143
  dropFetchTracker();
142
144
  if (fetchEndCalled || !fetchEndedHandler)
143
145
  return;
144
146
  fetchEndCalled = true;
145
147
  await fetchEndedHandler({
146
148
  fetchParams: { apiName, payload, headers },
147
- fetchResult,
149
+ fetchResult: outcome,
148
150
  activeFetchList: [...this.fetchTrackerList],
149
151
  });
150
152
  };
151
153
  let errorHandlerCalled = false;
152
- const reportError = async (err) => {
154
+ const reportError = async (error, failure) => {
153
155
  if (errorHandlerCalled || !errorHandler)
154
156
  return;
155
157
  errorHandlerCalled = true;
156
- await errorHandler(err);
158
+ await errorHandler(error, failure);
159
+ };
160
+ /**
161
+ * Ends a call no answer was read for: the attempt told, the lifecycle
162
+ * closed, and the failure reported, unless the site's own signal is
163
+ * what ended it. The site gave that call up itself (a superseded
164
+ * read, a view that closed), and reporting it would put a "could not
165
+ * reach the server" in front of a person for something the page chose.
166
+ */
167
+ const unansweredOutcome = async (failure, attemptOutcome) => {
168
+ idempotentAttempt.settle(attemptOutcome);
169
+ await fetchEnded(failure);
170
+ if (failure.reason !== 'aborted')
171
+ await reportError(failure.error, failure);
172
+ return failure;
157
173
  };
158
174
  // Timeout and abort wiring, shared with LambderInvokeCaller so the two
159
175
  // cannot drift on what a late or abandoned call means.
160
176
  const abort = createCallAbort({ timeoutMs: options?.timeoutMs ?? this.#timeoutMs, signal: options?.signal });
161
177
  const signal = abort.signal;
162
- /** Reports a call that was given up on, or null while it still stands. */
178
+ /** Ends a call that was given up on, or null while it still stands. */
163
179
  const abandonedOutcome = async (stage) => {
164
- const failure = abort.abortFailure(stage);
165
- if (!failure)
180
+ const abandoned = abort.abortFailure(stage);
181
+ if (!abandoned)
166
182
  return null;
167
- idempotentAttempt.settle(stage === "beforeSending" ? IDEMPOTENT_ATTEMPT_NOT_SENT : { ok: false, reason: failure.reason });
168
- await fetchEnded(failure.error);
169
- await reportError(failure.error);
170
- const outcome = { ok: false, reason: failure.reason, error: failure.error };
171
- return outcome;
183
+ const failure = { ok: false, reason: abandoned.reason, error: abandoned.error };
184
+ // Nothing sent tried nothing; a request that left may have run.
185
+ return await unansweredOutcome(failure, stage === "beforeSending" ? IDEMPOTENT_ATTEMPT_NOT_SENT : failure);
172
186
  };
173
187
  try {
174
188
  // A name that is not group.action names no endpoint and no path:
@@ -177,8 +191,9 @@ class LambderCallerCore {
177
191
  if (!splitApiName(apiName)) {
178
192
  idempotentAttempt.settle(IDEMPOTENT_ATTEMPT_NOT_SENT);
179
193
  const error = new Error(`Lambder: "${apiName}" is not an endpoint name. An endpoint is named group.action, both identifiers.`);
180
- await reportError(error);
181
- return { ok: false, reason: 'unknown', error };
194
+ const failure = { ok: false, reason: 'unknown', error };
195
+ await reportError(error, failure);
196
+ return failure;
182
197
  }
183
198
  this.fetchTrackerList.push(fetchTracker);
184
199
  if (fetchStartedHandler)
@@ -235,18 +250,15 @@ class LambderCallerCore {
235
250
  });
236
251
  }
237
252
  catch (err) {
238
- const wrappedError = coerceToError(err, "Request failed");
239
- // The caller's own abort wins, since only it knows about that.
240
- // Otherwise a transport that named its reason is believed:
241
- // "protocol" means something came back and was not an answer,
242
- // which is what this caller already calls `server`.
243
- const reason = abort.timedOut() ? 'timeout'
244
- : isLambderTransportFailure(err) && err.reason === 'protocol' ? 'server'
245
- : 'network';
246
- idempotentAttempt.settle({ ok: false, reason });
247
- await fetchEnded(wrappedError);
248
- await reportError(wrappedError);
249
- return { ok: false, reason, error: wrappedError };
253
+ // The caller's own abort wins, timeout or signal, since only it
254
+ // knows which aborted the call. Otherwise a transport that named
255
+ // its reason is believed: "protocol" means something came back
256
+ // and was not an answer, which is what this caller already
257
+ // calls `server`.
258
+ const reason = abort.abortReason()
259
+ ?? (isLambderTransportFailure(err) && err.reason === 'protocol' ? 'server' : 'network');
260
+ const failure = { ok: false, reason, error: coerceToError(err, "Request failed") };
261
+ return await unansweredOutcome(failure, failure);
250
262
  }
251
263
  // An answer that arrives after the call was given up on is not a
252
264
  // success. A transport that ignores request.signal resolves late,
@@ -271,25 +283,20 @@ class LambderCallerCore {
271
283
  for (const record of logList)
272
284
  console.log("[lambder]", record);
273
285
  }
286
+ await fetchEnded(outcome);
274
287
  if (!outcome.ok && outcome.reason === 'server') {
275
- await fetchEnded(outcome.error);
276
- await reportError(outcome.error);
288
+ await reportError(outcome.error, outcome);
277
289
  return outcome;
278
290
  }
279
291
  if (!outcome.ok && outcome.reason === 'validation') {
280
- await fetchEnded(null);
281
292
  if (apiInputValidationErrorHandler) {
282
293
  await apiInputValidationErrorHandler(outcome.zodError);
283
294
  }
284
295
  else {
285
- await reportError(new Error("API Input Validation Error", { cause: outcome.zodError }));
296
+ await reportError(new Error("API Input Validation Error", { cause: outcome.zodError }), outcome);
286
297
  }
287
298
  return outcome;
288
299
  }
289
- // Whatever is left carries the envelope: a success or one of the
290
- // envelope's own refusals, which is why no assertion is needed.
291
- const data = outcome.response;
292
- await fetchEnded(data);
293
300
  if (!outcome.ok && outcome.reason === 'versionExpired') {
294
301
  // A page asks for one reload at a time, however many of its
295
302
  // calls are refused. A call refused again after a reload
@@ -300,7 +307,7 @@ class LambderCallerCore {
300
307
  if (decision === "alreadyAsked")
301
308
  return outcome;
302
309
  if (decision === "loopConfirmed") {
303
- 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.`));
310
+ 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.`), outcome);
304
311
  return outcome;
305
312
  }
306
313
  await pageReloadLoopBreaker.runReloadAsk(async () => {
@@ -308,7 +315,7 @@ class LambderCallerCore {
308
315
  await versionExpiredHandler();
309
316
  }
310
317
  else {
311
- await reportError(new Error("Version Expired; Please refresh;"));
318
+ await reportError(new Error("Version Expired; Please refresh;"), outcome);
312
319
  }
313
320
  });
314
321
  return outcome;
@@ -334,7 +341,7 @@ class LambderCallerCore {
334
341
  await sessionExpiredHandler();
335
342
  }
336
343
  else {
337
- await reportError(new Error("Session Expired; Please log in again;"));
344
+ await reportError(new Error("Session Expired; Please log in again;"), outcome);
338
345
  }
339
346
  return outcome;
340
347
  }
@@ -343,7 +350,7 @@ class LambderCallerCore {
343
350
  await notAuthorizedHandler();
344
351
  }
345
352
  else {
346
- await reportError(new Error("Not Authorized;"));
353
+ await reportError(new Error("Not Authorized;"), outcome);
347
354
  }
348
355
  return outcome;
349
356
  }
@@ -358,13 +365,13 @@ class LambderCallerCore {
358
365
  catch (err) {
359
366
  // Escape hatch for anything above (typically an app handler throwing):
360
367
  // dispatch never throws, so api()/apiOutcome() call sites never do.
361
- const wrappedError = coerceToError(err, "The call failed before it produced an outcome");
368
+ const failure = { ok: false, reason: 'unknown', error: coerceToError(err, "The call failed before it produced an outcome") };
362
369
  try {
363
- await fetchEnded(wrappedError);
364
- await reportError(wrappedError);
370
+ await fetchEnded(failure);
371
+ await reportError(failure.error, failure);
365
372
  }
366
373
  catch { /* an app handler threw again; never propagate */ }
367
- return { ok: false, reason: 'unknown', error: wrappedError };
374
+ return failure;
368
375
  }
369
376
  finally {
370
377
  // Whatever ended the call before its attempt was told (a provider
@@ -398,8 +405,9 @@ class LambderCallerCore {
398
405
  * The endpoint's output on success, `undefined` on every failure. An
399
406
  * output is always an object or an array, so the result is truthy exactly
400
407
  * when the call succeeded; the handlers configured on the caller have
401
- * already been told why it did not. Use apiOutcome() to branch on the
402
- * reason at the call site.
408
+ * already been told why it did not, unless the call's own signal aborted
409
+ * it, which tells none. Use apiOutcome() to branch on the reason at the
410
+ * call site.
403
411
  */
404
412
  async api(apiName, ...rest) {
405
413
  const [payload, options] = rest;
@@ -1,4 +1,5 @@
1
1
  import { type LambderUploadFileFacts, type LambderUploadRule, type LambderUploadRuleVerdict, type LambderUploadTicket } from "../shared/contracts/LambderUploadBucket.js";
2
+ import type { LambderApiFailure, LambderApiOutcome } from "../shared/wire/LambderApiOutcome.js";
2
3
  /** Where an upload is, in order. `sentBytes` only moves during `uploading`. */
3
4
  export type LambderUploadPhase = "hashing" | "requesting" | "uploading" | "confirming";
4
5
  export type LambderUploadProgress = {
@@ -9,50 +10,74 @@ export type LambderUploadProgress = {
9
10
  export type LambderUploadFailureReason = LambderUploadRuleVerdict
10
11
  /** The browser could not read the file (moved, deleted, or a cloud placeholder that never downloaded). */
11
12
  | "fileUnreadable"
12
- /** The app's ticket endpoint would not issue a ticket. */
13
+ /** The app's ticket endpoint would not issue a ticket: it refused, or its call failed in a way a retry cannot cure. */
13
14
  | "ticketRefused"
14
15
  /** Storage answered and said no, for a reason a retry cannot cure. */
15
16
  | "storageRejected"
16
- /** Storage could not be reached, or kept stalling, through every attempt. */
17
+ /** Storage, or the app's ticket or confirm endpoint, could not be reached, or kept stalling or failing, through every attempt. */
17
18
  | "networkFailed"
18
- /** The bytes are stored, and the app's confirm endpoint would not confirm them. */
19
- | "confirmRefused" | "cancelled";
20
- /** How an upload failed: `reason` for a screen to word, the underlying error as `cause`. */
19
+ /** The bytes are stored, and the app's confirm endpoint would not confirm them: it refused, or its call failed in a way a retry cannot cure. */
20
+ | "confirmRefused"
21
+ /** The upload's signal aborted it. */
22
+ | "aborted";
23
+ /**
24
+ * How an upload failed: `reason` for a screen to word, the underlying error
25
+ * as `cause`, and the failed outcome of the app's own call as `callFailure`
26
+ * when that call is what ended the upload, so a screen words a refusal by its
27
+ * code (`callFailure.refusal.code`) as it would on the call itself.
28
+ */
21
29
  export declare class LambderUploadError extends Error {
22
30
  readonly reason: LambderUploadFailureReason;
31
+ /** The ticket or confirm call's failure outcome, when the upload ended on one. */
32
+ readonly callFailure?: LambderApiFailure;
23
33
  constructor(reason: LambderUploadFailureReason, options?: {
24
34
  cause?: unknown;
25
35
  detail?: string;
36
+ callFailure?: LambderApiFailure;
26
37
  });
27
38
  }
28
- export type LambderUploadRunnerOptions<Reference, Receipt> = {
39
+ /**
40
+ * What a runner is built from. The app's ticket and confirm calls answer with
41
+ * the outcome a caller's `.outcome()` resolves to, so the runner can tell a
42
+ * call that got no usable answer, which it tries again, from a refusal, which
43
+ * it does not.
44
+ */
45
+ export type LambderUploadRunnerOptions<TicketOutput extends {
46
+ ticket: LambderUploadTicket;
47
+ }, Receipt> = {
29
48
  uploadRule: LambderUploadRule;
30
49
  /**
31
- * The app's ticket endpoint. `reference` is whatever its confirm endpoint
32
- * needs to find this upload again (the id of the record it made), and
33
- * means nothing to the runner. `signal` is the upload's own, for the call
34
- * to pass on so a cancel stops it too.
50
+ * The app's ticket endpoint, as its outcome
51
+ * (`caller.invoices.requestUpload.outcome(input, { signal })`). Its output
52
+ * carries the `ticket` and whatever the confirm endpoint needs to find this
53
+ * upload again (the id of the record it made), which the runner hands to
54
+ * confirmUpload unread. `signal` is the upload's own, for the call to pass
55
+ * on so an abort stops it too.
35
56
  */
36
57
  requestTicket: (fileFacts: LambderUploadFileFacts, call: {
37
58
  signal: AbortSignal | undefined;
38
- }) => Promise<{
39
- ticket: LambderUploadTicket;
40
- reference: Reference;
41
- }>;
42
- /** The app's confirm endpoint: the server checks the stored object and answers its record of it. */
43
- confirmUpload: (reference: Reference, call: {
59
+ }) => Promise<LambderApiOutcome<TicketOutput>>;
60
+ /**
61
+ * The app's confirm endpoint, as its outcome: handed the ticket
62
+ * endpoint's output, the server checks the stored object and answers its
63
+ * record of it, the receipt upload() resolves to.
64
+ */
65
+ confirmUpload: (ticketOutput: TicketOutput, call: {
44
66
  signal: AbortSignal | undefined;
45
- }) => Promise<Receipt>;
67
+ }) => Promise<LambderApiOutcome<Receipt>>;
46
68
  /** The app's way of forgetting a confirmed upload the person removed again. Without one, discard() does nothing. */
47
69
  discardUpload?: (receipt: Receipt) => Promise<void>;
48
70
  /**
49
- * How storage is tried again when it cannot be reached, stalls, or answers
50
- * a failure a retry can cure (a 5xx, RequestTimeout, SlowDown). Each wait
51
- * is `baseDelayMs` plus a random share of a ceiling that starts at
52
- * `baseDelayMs` and doubles with every failed attempt, the whole never
53
- * past `maxDelayMs` (the ladder of LambderBackoffTimer), so many browsers
54
- * dropped together do not come back in step, not even the first time.
55
- * Default: 4 attempts, waits from one second to 15.
71
+ * How a step is tried again when it got no usable answer: storage that
72
+ * cannot be reached, stalls, or answers a failure a retry can cure (a 5xx,
73
+ * RequestTimeout, SlowDown), and a ticket or confirm call that failed as
74
+ * `network`, `timeout` or `server`. Each wait is `baseDelayMs` plus a
75
+ * random share of a ceiling that starts at `baseDelayMs` and doubles with
76
+ * every failed attempt, the whole never past `maxDelayMs` (the ladder of
77
+ * LambderBackoffTimer), so many browsers dropped together do not come
78
+ * back in step, not even the first time. The attempts are per step: the
79
+ * ladder starts again after each step that succeeds. Default: 4
80
+ * attempts, waits from one second to 15.
56
81
  */
57
82
  storageRetry?: {
58
83
  attempts?: number;
@@ -67,13 +92,16 @@ export type LambderUploadRunnerOptions<Reference, Receipt> = {
67
92
  */
68
93
  stallTimeoutMs?: number;
69
94
  };
70
- export declare class LambderUploadRunner<Reference, Receipt> {
95
+ export declare class LambderUploadRunner<TicketOutput extends {
96
+ ticket: LambderUploadTicket;
97
+ }, Receipt> {
71
98
  private readonly options;
72
- private readonly attempts;
99
+ /** How many times a step is tried again after its first try: the storageRetry option's attempts, less that first one. */
100
+ private readonly retries;
73
101
  /** The ladder one upload's waits climb; each upload() builds a timer of its own from it, so two uploads never share a count. */
74
102
  private readonly backoff;
75
103
  private readonly stallTimeoutMs;
76
- constructor(options: LambderUploadRunnerOptions<Reference, Receipt>);
104
+ constructor(options: LambderUploadRunnerOptions<TicketOutput, Receipt>);
77
105
  /** For a file input's `accept`, so the picker only offers what the rule takes. */
78
106
  get acceptedTypes(): string;
79
107
  get maxBytes(): number;