lambder 7.3.1 → 8.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. package/CHANGELOG.md +1047 -3
  2. package/README.md +46 -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 +68 -62
  13. package/dist/api/LambderApiIdempotency.js +214 -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 +47 -38
  17. package/dist/api/LambderApiPipeline.js +122 -63
  18. package/dist/api/LambderApiRateLimits.d.ts +201 -54
  19. package/dist/api/LambderApiRateLimits.js +185 -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/ContractTypePrinter.d.ts +85 -0
  27. package/dist/build/ContractTypePrinter.js +402 -0
  28. package/dist/build/freshProcessVerifier.d.ts +13 -0
  29. package/dist/build/freshProcessVerifier.js +19 -0
  30. package/dist/build/moduleLocation.d.ts +11 -0
  31. package/dist/build/moduleLocation.js +6 -0
  32. package/dist/build/writeApiContract.d.ts +78 -0
  33. package/dist/build/writeApiContract.js +302 -0
  34. package/dist/build/writeApiSignatures.d.ts +114 -0
  35. package/dist/build/writeApiSignatures.js +217 -0
  36. package/dist/build/writeFileAtomically.d.ts +8 -0
  37. package/dist/build/writeFileAtomically.js +22 -0
  38. package/dist/build.d.ts +14 -0
  39. package/dist/build.js +11 -0
  40. package/dist/client/LambderCaller.d.ts +13 -44
  41. package/dist/client/LambderCaller.js +77 -84
  42. package/dist/client/LambderReloadLoopBreaker.d.ts +56 -26
  43. package/dist/client/LambderReloadLoopBreaker.js +90 -46
  44. package/dist/client/LambderUploadRunner.d.ts +96 -0
  45. package/dist/client/LambderUploadRunner.js +234 -0
  46. package/dist/client/lambderFetchTransport.d.ts +4 -1
  47. package/dist/client/lambderFetchTransport.js +52 -28
  48. package/dist/client.d.ts +9 -3
  49. package/dist/client.js +6 -1
  50. package/dist/core/Lambder.d.ts +143 -79
  51. package/dist/core/Lambder.js +350 -231
  52. package/dist/core/LambderContext.d.ts +82 -15
  53. package/dist/core/LambderContext.js +107 -20
  54. package/dist/core/LambderCors.d.ts +21 -3
  55. package/dist/core/LambderCors.js +35 -16
  56. package/dist/core/LambderCrashHandling.d.ts +40 -0
  57. package/dist/core/LambderCrashHandling.js +97 -0
  58. package/dist/core/LambderCreateOptions.d.ts +151 -75
  59. package/dist/core/LambderCreateOptions.js +16 -23
  60. package/dist/core/LambderFiles.d.ts +21 -7
  61. package/dist/core/LambderFiles.js +62 -34
  62. package/dist/core/LambderIndexHtml.js +12 -11
  63. package/dist/core/LambderPolicyBuilders.d.ts +17 -5
  64. package/dist/core/LambderPolicyBuilders.js +17 -5
  65. package/dist/core/LambderPublicFiles.d.ts +11 -5
  66. package/dist/core/LambderPublicFiles.js +32 -4
  67. package/dist/core/LambderRequestPath.d.ts +43 -0
  68. package/dist/core/LambderRequestPath.js +63 -0
  69. package/dist/core/LambderResponse.d.ts +26 -5
  70. package/dist/core/LambderResponse.js +157 -70
  71. package/dist/core/LambderResponseBuilder.d.ts +49 -4
  72. package/dist/core/LambderResponseBuilder.js +64 -3
  73. package/dist/core/LambderRouting.d.ts +2 -3
  74. package/dist/core/LambderRouting.js +22 -7
  75. package/dist/core/LambderTemplatingEngine.js +211 -32
  76. package/dist/index.d.ts +25 -8
  77. package/dist/index.js +13 -4
  78. package/dist/invoke/LambderInvokeCaller.d.ts +37 -42
  79. package/dist/invoke/LambderInvokeCaller.js +76 -66
  80. package/dist/invoke/LambderInvokeOutcome.d.ts +27 -26
  81. package/dist/invoke/LambderInvokeOutcome.js +9 -22
  82. package/dist/invoke/LambderLambdaEvent.d.ts +29 -9
  83. package/dist/invoke/LambderLambdaEvent.js +40 -22
  84. package/dist/invoke/lambderHandlerTransport.d.ts +9 -10
  85. package/dist/invoke/lambderHandlerTransport.js +15 -18
  86. package/dist/mock/LambderMockApp.d.ts +67 -83
  87. package/dist/mock/LambderMockApp.js +167 -153
  88. package/dist/mock/LambderMockBrowserCookies.d.ts +24 -28
  89. package/dist/mock/LambderMockBrowserCookies.js +24 -28
  90. package/dist/mock/LambderMockCallRecorder.d.ts +15 -22
  91. package/dist/mock/LambderMockCallRecorder.js +19 -28
  92. package/dist/mock/LambderMockCreateOptions.d.ts +42 -24
  93. package/dist/mock/LambderMockEntryRegistry.d.ts +11 -12
  94. package/dist/mock/LambderMockEntryRegistry.js +24 -29
  95. package/dist/mock/LambderMockFailureInjector.d.ts +3 -6
  96. package/dist/mock/LambderMockFailureInjector.js +3 -6
  97. package/dist/mock/LambderMockTypes.d.ts +78 -108
  98. package/dist/mock/lambderMockInvokeTransport.d.ts +11 -13
  99. package/dist/mock/lambderMockInvokeTransport.js +11 -10
  100. package/dist/mock/lambderMockMswHandler.d.ts +43 -33
  101. package/dist/mock/lambderMockMswHandler.js +50 -39
  102. package/dist/mock/lambderMockUploadMswHandler.d.ts +26 -0
  103. package/dist/mock/lambderMockUploadMswHandler.js +28 -0
  104. package/dist/mock.d.ts +4 -1
  105. package/dist/mock.js +6 -3
  106. package/dist/session/LambderSessionController.d.ts +108 -89
  107. package/dist/session/LambderSessionController.js +187 -168
  108. package/dist/session/LambderSessionCrypto.d.ts +16 -7
  109. package/dist/session/LambderSessionCrypto.js +26 -12
  110. package/dist/session/LambderSessionManager.d.ts +124 -46
  111. package/dist/session/LambderSessionManager.js +262 -137
  112. package/dist/shared/LambderHtml.d.ts +42 -3
  113. package/dist/shared/LambderHtml.js +127 -7
  114. package/dist/shared/LambderHtmlPositions.d.ts +173 -0
  115. package/dist/shared/LambderHtmlPositions.js +652 -0
  116. package/dist/shared/LambderI18n.d.ts +10 -11
  117. package/dist/shared/LambderI18n.js +33 -21
  118. package/dist/shared/contracts/LambderCache.d.ts +66 -0
  119. package/dist/shared/contracts/LambderCache.js +11 -0
  120. package/dist/shared/contracts/LambderFileSource.d.ts +6 -6
  121. package/dist/shared/contracts/LambderFileSource.js +5 -8
  122. package/dist/shared/contracts/LambderIdempotencyStore.d.ts +51 -22
  123. package/dist/shared/contracts/LambderIdempotencyStore.js +4 -5
  124. package/dist/shared/contracts/LambderRateLimiter.d.ts +27 -15
  125. package/dist/shared/contracts/LambderRateLimiter.js +4 -5
  126. package/dist/shared/contracts/LambderSessionStore.d.ts +65 -26
  127. package/dist/shared/contracts/LambderSessionStore.js +5 -6
  128. package/dist/shared/contracts/LambderUploadBucket.d.ts +154 -0
  129. package/dist/shared/contracts/LambderUploadBucket.js +74 -0
  130. package/dist/shared/transport/LambderApiTransport.d.ts +27 -27
  131. package/dist/shared/transport/LambderApiTransport.js +7 -7
  132. package/dist/shared/transport/LambderCookieJar.d.ts +28 -35
  133. package/dist/shared/transport/LambderCookieJar.js +54 -66
  134. package/dist/shared/transport/lambderCookieJarTransport.d.ts +11 -13
  135. package/dist/shared/transport/lambderCookieJarTransport.js +24 -23
  136. package/dist/shared/util/LambderCallAbort.d.ts +5 -5
  137. package/dist/shared/util/LambderCallAbort.js +5 -5
  138. package/dist/shared/util/LambderClientIp.d.ts +27 -11
  139. package/dist/shared/util/LambderClientIp.js +96 -13
  140. package/dist/shared/util/LambderContentDisposition.d.ts +10 -0
  141. package/dist/shared/util/LambderContentDisposition.js +13 -0
  142. package/dist/shared/util/LambderExpiringMap.d.ts +35 -49
  143. package/dist/shared/util/LambderExpiringMap.js +41 -57
  144. package/dist/shared/util/LambderNodeModules.js +6 -7
  145. package/dist/shared/util/LambderOptionChecks.d.ts +4 -4
  146. package/dist/shared/util/LambderOptionChecks.js +4 -4
  147. package/dist/shared/util/LambderResponseBrand.d.ts +5 -5
  148. package/dist/shared/util/LambderResponseBrand.js +5 -5
  149. package/dist/shared/util/LambderTextDigest.d.ts +7 -5
  150. package/dist/shared/util/LambderTextDigest.js +11 -5
  151. package/dist/shared/util/LambderTypeUtilities.d.ts +7 -8
  152. package/dist/shared/util/LambderTypeUtilities.js +3 -3
  153. package/dist/shared/util/boundKeyField.d.ts +20 -0
  154. package/dist/shared/util/boundKeyField.js +34 -0
  155. package/dist/shared/util/canonicalJson.d.ts +11 -0
  156. package/dist/shared/util/canonicalJson.js +28 -0
  157. package/dist/shared/util/joinKeyFields.d.ts +20 -0
  158. package/dist/shared/util/joinKeyFields.js +22 -0
  159. package/dist/shared/wire/LambderAnswerHeaders.d.ts +12 -16
  160. package/dist/shared/wire/LambderAnswerHeaders.js +12 -16
  161. package/dist/shared/wire/LambderApiContract.d.ts +98 -53
  162. package/dist/shared/wire/LambderApiOutcome.d.ts +43 -31
  163. package/dist/shared/wire/LambderApiOutcome.js +48 -23
  164. package/dist/shared/wire/LambderApiRefusal.d.ts +45 -27
  165. package/dist/shared/wire/LambderApiRefusal.js +42 -7
  166. package/dist/shared/wire/LambderApiSignature.d.ts +18 -22
  167. package/dist/shared/wire/LambderApiSignature.js +16 -19
  168. package/dist/shared/wire/LambderCallOptions.d.ts +38 -47
  169. package/dist/shared/wire/LambderCallOptions.js +9 -11
  170. package/dist/shared/wire/LambderCompressionCodec.d.ts +29 -34
  171. package/dist/shared/wire/LambderCompressionCodec.js +31 -36
  172. package/dist/shared/wire/LambderCompressionOption.d.ts +9 -9
  173. package/dist/shared/wire/LambderCompressionOption.js +9 -9
  174. package/dist/shared/wire/LambderCrashDetail.d.ts +12 -15
  175. package/dist/shared/wire/LambderCrashDetail.js +12 -15
  176. package/dist/shared/wire/LambderDefaultApiPath.d.ts +6 -0
  177. package/dist/shared/wire/LambderDefaultApiPath.js +6 -0
  178. package/dist/shared/wire/LambderHttpStatus.d.ts +6 -7
  179. package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +89 -0
  180. package/dist/shared/wire/LambderIdempotencyKeyScope.js +146 -0
  181. package/dist/shared/wire/LambderInvokeApiId.d.ts +27 -0
  182. package/dist/shared/wire/LambderInvokeApiId.js +27 -0
  183. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +6 -7
  184. package/dist/shared/wire/LambderOutcomeAssertions.js +6 -7
  185. package/dist/shared/wire/LambderRequestPayload.d.ts +18 -20
  186. package/dist/shared/wire/LambderRequestPayload.js +4 -6
  187. package/dist/shared/wire/LambderUploadObjectFields.d.ts +10 -0
  188. package/dist/shared/wire/LambderUploadObjectFields.js +24 -0
  189. package/dist/shared/wire/LambderUploadRefusal.d.ts +9 -0
  190. package/dist/shared/wire/LambderUploadRefusal.js +18 -0
  191. package/dist/shared/wire/LambderUploadSchemas.d.ts +12 -0
  192. package/dist/shared/wire/LambderUploadSchemas.js +30 -0
  193. package/dist/stores/LambderCacheFiller.d.ts +48 -0
  194. package/dist/stores/LambderCacheFiller.js +119 -0
  195. package/dist/stores/LambderCacheKeys.d.ts +26 -0
  196. package/dist/stores/LambderCacheKeys.js +54 -0
  197. package/dist/stores/LambderCacheValues.d.ts +45 -0
  198. package/dist/stores/LambderCacheValues.js +74 -0
  199. package/dist/stores/LambderDdbCache.d.ts +121 -56
  200. package/dist/stores/LambderDdbCache.js +528 -225
  201. package/dist/stores/LambderDdbIdempotencyStore.d.ts +33 -22
  202. package/dist/stores/LambderDdbIdempotencyStore.js +75 -50
  203. package/dist/stores/LambderDdbRateLimiter.d.ts +76 -20
  204. package/dist/stores/LambderDdbRateLimiter.js +151 -39
  205. package/dist/stores/LambderDdbSdk.d.ts +43 -31
  206. package/dist/stores/LambderDdbSdk.js +80 -38
  207. package/dist/stores/LambderDdbSessionStore.d.ts +27 -14
  208. package/dist/stores/LambderDdbSessionStore.js +119 -47
  209. package/dist/stores/LambderHttpFileSource.d.ts +15 -6
  210. package/dist/stores/LambderHttpFileSource.js +15 -13
  211. package/dist/stores/LambderMemoryCache.d.ts +49 -0
  212. package/dist/stores/LambderMemoryCache.js +113 -0
  213. package/dist/stores/LambderMemoryIdempotencyStore.d.ts +13 -12
  214. package/dist/stores/LambderMemoryIdempotencyStore.js +31 -30
  215. package/dist/stores/LambderMemoryRateLimiter.d.ts +8 -9
  216. package/dist/stores/LambderMemoryRateLimiter.js +14 -13
  217. package/dist/stores/LambderMemorySessionStore.d.ts +14 -11
  218. package/dist/stores/LambderMemorySessionStore.js +38 -19
  219. package/dist/stores/LambderMemoryUploadBucket.d.ts +99 -0
  220. package/dist/stores/LambderMemoryUploadBucket.js +219 -0
  221. package/dist/stores/LambderS3FileSource.d.ts +21 -6
  222. package/dist/stores/LambderS3FileSource.js +12 -7
  223. package/dist/stores/LambderS3UploadBucket.d.ts +73 -0
  224. package/dist/stores/LambderS3UploadBucket.js +144 -0
  225. package/dist/stores/LambderSdkInstallHint.d.ts +11 -0
  226. package/dist/stores/LambderSdkInstallHint.js +14 -0
  227. package/dist/testing/LambderTestApp.d.ts +23 -25
  228. package/dist/testing/LambderTestApp.js +22 -24
  229. package/dist/testing/LambderTestVisitor.d.ts +10 -12
  230. package/dist/testing/LambderTestVisitor.js +15 -15
  231. package/dist/testing.d.ts +3 -0
  232. package/dist/testing.js +2 -0
  233. package/package.json +26 -3
  234. package/dist/api/LambderApiPolicyEngine.d.ts +0 -47
  235. package/dist/api/LambderApiPolicyEngine.js +0 -85
  236. package/dist/shared/util/LambderKeyFields.d.ts +0 -32
  237. package/dist/shared/util/LambderKeyFields.js +0 -34
@@ -1,71 +1,115 @@
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 const RELOAD_LOOP_WINDOW_MS = 5 * 60 * 1000;
28
49
  const STORAGE_KEY = "lambder:version-expired";
29
- const isExpiredRecord = (value) => typeof value === "object" && value !== null
30
- && typeof value.apiName === "string"
31
- && typeof value.signature === "string"
50
+ const isRefusedCall = (value) => Array.isArray(value) && value.length === 4
51
+ && typeof value[0] === "string" && typeof value[1] === "string" && typeof value[2] === "string"
52
+ && typeof value[3] === "number";
53
+ const isVersionExpiredRecord = (value) => typeof value === "object" && value !== null
32
54
  && typeof value.at === "number"
33
- && typeof value.confirmed === "boolean";
55
+ && typeof value.confirmed === "boolean"
56
+ && Array.isArray(value.calls)
57
+ && value.calls.every(isRefusedCall);
58
+ /** A record from the future (a clock set back) is outside the window rather than inside it forever. */
59
+ const isWithinWindow = (at, now) => now >= at && now - at < RELOAD_LOOP_WINDOW_MS;
34
60
  export class LambderReloadLoopBreaker {
35
- /** The record when sessionStorage is unavailable; sessionStorage is read first wherever it exists. */
36
- memory = null;
37
- /**
38
- * Records this versionExpired and says whether it repeats a recent one,
39
- * in which case the caller must not invoke versionExpiredHandler again.
40
- */
41
- isRepeat(apiName, signature, now = Date.now()) {
42
- const last = this.read();
43
- if (last && now - last.at < RELOAD_LOOP_WINDOW_MS && (last.confirmed || (last.apiName === apiName && last.signature === signature))) {
44
- // `at` stays the first event's, so the window runs from the start
45
- // of the loop rather than being pushed forward by every repeat.
46
- this.write({ apiName, signature, at: last.at, confirmed: true });
47
- return true;
61
+ loadedAt;
62
+ /** Whether the handler this page asked is still running: what the page hears meanwhile stays quiet. */
63
+ reloadAskPending = false;
64
+ /** loadedAt: when this document loaded. A call refused before it was refused by an earlier load, with a reload in between. */
65
+ constructor(loadedAt = performance.timeOrigin) {
66
+ this.loadedAt = loadedAt;
67
+ }
68
+ /** Records this versionExpired and decides what the caller does about it. */
69
+ recordVersionExpired(apiName, signature, version, now = Date.now()) {
70
+ const stored = this.read();
71
+ const record = stored && isWithinWindow(stored.at, now) ? stored : { at: now, confirmed: false, calls: [] };
72
+ const earlier = record.calls.find(([name, sig, ver]) => name === apiName && sig === signature && ver === version);
73
+ if (!earlier)
74
+ record.calls.push([apiName, signature, version, now]);
75
+ let decision;
76
+ if (this.reloadAskPending)
77
+ decision = "alreadyAsked";
78
+ else if (record.confirmed || (earlier !== undefined && earlier[3] < this.loadedAt))
79
+ decision = "loopConfirmed";
80
+ else
81
+ decision = "askForReload";
82
+ if (decision === "loopConfirmed")
83
+ record.confirmed = true;
84
+ this.write(record);
85
+ return decision;
86
+ }
87
+ /** Runs the ask an askForReload decision calls for; until it settles, every versionExpired is alreadyAsked. */
88
+ async runReloadAsk(ask) {
89
+ this.reloadAskPending = true;
90
+ try {
91
+ await ask();
92
+ }
93
+ finally {
94
+ this.reloadAskPending = false;
48
95
  }
49
- this.write({ apiName, signature, at: now, confirmed: false });
50
- return false;
51
96
  }
52
97
  read() {
53
98
  try {
54
- const raw = globalThis.sessionStorage?.getItem(STORAGE_KEY);
55
- if (raw) {
56
- const parsed = JSON.parse(raw);
57
- if (isExpiredRecord(parsed))
99
+ const stored = globalThis.sessionStorage?.getItem(STORAGE_KEY);
100
+ if (stored) {
101
+ const parsed = JSON.parse(stored);
102
+ if (isVersionExpiredRecord(parsed))
58
103
  return parsed;
59
104
  }
60
105
  }
61
- catch { /* a private window or blocked storage: the in-memory record stands in */ }
62
- return this.memory;
106
+ catch { /* blocked storage, or a record that is not JSON: read as no record */ }
107
+ return null;
63
108
  }
64
109
  write(record) {
65
- this.memory = record;
66
110
  try {
67
111
  globalThis.sessionStorage?.setItem(STORAGE_KEY, JSON.stringify(record));
68
112
  }
69
- catch { /* same: the in-memory record stands in */ }
113
+ catch { /* blocked or full: nothing outlives this page */ }
70
114
  }
71
115
  }
@@ -0,0 +1,96 @@
1
+ import { type LambderUploadFileFacts, type LambderUploadRule, type LambderUploadRuleVerdict, type LambderUploadTicket } from "../shared/contracts/LambderUploadBucket.js";
2
+ /** Where an upload is, in order. `sentBytes` only moves during `uploading`. */
3
+ export type LambderUploadPhase = "hashing" | "requesting" | "uploading" | "confirming";
4
+ export type LambderUploadProgress = {
5
+ phase: LambderUploadPhase;
6
+ sentBytes: number;
7
+ totalBytes: number;
8
+ };
9
+ export type LambderUploadFailureReason = LambderUploadRuleVerdict
10
+ /** The browser could not read the file (moved, deleted, or a cloud placeholder that never downloaded). */
11
+ | "fileUnreadable"
12
+ /** The app's ticket endpoint would not issue a ticket. */
13
+ | "ticketRefused"
14
+ /** Storage answered and said no, for a reason a retry cannot cure. */
15
+ | "storageRejected"
16
+ /** Storage could not be reached, or kept stalling, through every attempt. */
17
+ | "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`. */
21
+ export declare class LambderUploadError extends Error {
22
+ readonly reason: LambderUploadFailureReason;
23
+ constructor(reason: LambderUploadFailureReason, options?: {
24
+ cause?: unknown;
25
+ detail?: string;
26
+ });
27
+ }
28
+ export type LambderUploadRunnerOptions<Reference, Receipt> = {
29
+ uploadRule: LambderUploadRule;
30
+ /**
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.
35
+ */
36
+ requestTicket: (fileFacts: LambderUploadFileFacts, call: {
37
+ 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: {
44
+ signal: AbortSignal | undefined;
45
+ }) => Promise<Receipt>;
46
+ /** The app's way of forgetting a confirmed upload the person removed again. Without one, discard() does nothing. */
47
+ discardUpload?: (receipt: Receipt) => Promise<void>;
48
+ /**
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 a random time between `baseDelayMs` and a ceiling that doubles with
52
+ * every failed attempt, never past `maxDelayMs`, so many browsers dropped
53
+ * together do not come back in step. Default: 4 attempts, waits from one
54
+ * second to 15.
55
+ */
56
+ storageRetry?: {
57
+ attempts?: number;
58
+ baseDelayMs?: number;
59
+ maxDelayMs?: number;
60
+ };
61
+ /**
62
+ * How long a post may be open and move nothing before it counts as
63
+ * dropped. Default: 60 seconds. Watched where XMLHttpRequest exists,
64
+ * which reports a body's progress; a runtime with only fetch posts
65
+ * unwatched.
66
+ */
67
+ stallTimeoutMs?: number;
68
+ };
69
+ export declare class LambderUploadRunner<Reference, Receipt> {
70
+ private readonly options;
71
+ private readonly attempts;
72
+ private readonly baseDelayMs;
73
+ private readonly maxDelayMs;
74
+ private readonly stallTimeoutMs;
75
+ constructor(options: LambderUploadRunnerOptions<Reference, Receipt>);
76
+ /** For a file input's `accept`, so the picker only offers what the rule takes. */
77
+ get acceptedTypes(): string;
78
+ get maxBytes(): number;
79
+ /** The rule's verdict on a file, or null when it may be uploaded. Costs nothing, so a screen can ask on drop. */
80
+ checkFile(file: Blob): LambderUploadRuleVerdict | null;
81
+ /**
82
+ * Uploads one file and answers the app's receipt, or throws a
83
+ * LambderUploadError. Aborting `signal` stops it wherever it is: the
84
+ * runner's own steps at once, and the app's calls as far as they pass
85
+ * the signal on.
86
+ */
87
+ upload(file: File, { onProgress, signal }?: {
88
+ onProgress?: (progress: LambderUploadProgress) => void;
89
+ signal?: AbortSignal;
90
+ }): Promise<Receipt>;
91
+ /** Forgets a confirmed upload through the app's endpoint, when it declared one. */
92
+ discard(receipt: Receipt): Promise<void>;
93
+ private waitBeforeRetry;
94
+ /** One post of the file to storage. Never throws: every ending is an outcome. */
95
+ private post;
96
+ }
@@ -0,0 +1,234 @@
1
+ import { checkUploadRule, } from "../shared/contracts/LambderUploadBucket.js";
2
+ import { sha256Base64Of } from "../shared/util/LambderTextDigest.js";
3
+ /** How an upload failed: `reason` for a screen to word, the underlying error as `cause`. */
4
+ export class LambderUploadError extends Error {
5
+ reason;
6
+ constructor(reason, options = {}) {
7
+ super(options.detail ? `${reason}: ${options.detail}` : reason, { cause: options.cause });
8
+ this.name = "LambderUploadError";
9
+ this.reason = reason;
10
+ }
11
+ }
12
+ /**
13
+ * How many times one upload asks for a new ticket because storage called the
14
+ * last one expired. A new ticket is asked for at once and spends no attempt
15
+ * at storage; the bound is for a clock so far off that every ticket arrives
16
+ * expired.
17
+ */
18
+ const TICKET_RENEWAL_LIMIT = 2;
19
+ /** S3's refusals that are the connection's or the service's fault rather than the file's, which its own SDK retries too. */
20
+ const TRANSIENT_STORAGE_CODES = new Set(["RequestTimeout", "SlowDown", "InternalError", "ServiceUnavailable"]);
21
+ export class LambderUploadRunner {
22
+ options;
23
+ attempts;
24
+ baseDelayMs;
25
+ maxDelayMs;
26
+ stallTimeoutMs;
27
+ constructor(options) {
28
+ this.options = options;
29
+ this.attempts = Math.max(1, options.storageRetry?.attempts ?? 4);
30
+ this.baseDelayMs = options.storageRetry?.baseDelayMs ?? 1_000;
31
+ this.maxDelayMs = options.storageRetry?.maxDelayMs ?? 15_000;
32
+ this.stallTimeoutMs = options.stallTimeoutMs ?? 60_000;
33
+ }
34
+ /** For a file input's `accept`, so the picker only offers what the rule takes. */
35
+ get acceptedTypes() {
36
+ return this.options.uploadRule.mimeTypes.join(",");
37
+ }
38
+ get maxBytes() {
39
+ return this.options.uploadRule.maxBytes;
40
+ }
41
+ /** The rule's verdict on a file, or null when it may be uploaded. Costs nothing, so a screen can ask on drop. */
42
+ checkFile(file) {
43
+ return checkUploadRule(this.options.uploadRule, { mimeType: file.type, byteSize: file.size });
44
+ }
45
+ /**
46
+ * Uploads one file and answers the app's receipt, or throws a
47
+ * LambderUploadError. Aborting `signal` stops it wherever it is: the
48
+ * runner's own steps at once, and the app's calls as far as they pass
49
+ * the signal on.
50
+ */
51
+ async upload(file, { onProgress, signal } = {}) {
52
+ const rejection = this.checkFile(file);
53
+ if (rejection)
54
+ throw new LambderUploadError(rejection);
55
+ const report = (phase, sentBytes = 0) => onProgress?.({ phase, sentBytes, totalBytes: file.size });
56
+ const stopIfCancelled = () => {
57
+ if (signal?.aborted)
58
+ throw new LambderUploadError("cancelled");
59
+ };
60
+ stopIfCancelled();
61
+ report("hashing");
62
+ let bytes;
63
+ try {
64
+ bytes = new Uint8Array(await file.arrayBuffer());
65
+ }
66
+ catch (cause) {
67
+ throw new LambderUploadError("fileUnreadable", { cause });
68
+ }
69
+ const fileFacts = {
70
+ fileName: file.name,
71
+ mimeType: file.type,
72
+ byteSize: file.size,
73
+ sha256Base64: await sha256Base64Of(bytes),
74
+ };
75
+ stopIfCancelled();
76
+ const requestTicket = async () => {
77
+ report("requesting");
78
+ try {
79
+ return await this.options.requestTicket(fileFacts, { signal });
80
+ }
81
+ catch (cause) {
82
+ throw new LambderUploadError(signal?.aborted ? "cancelled" : "ticketRefused", { cause });
83
+ }
84
+ };
85
+ let issued = await requestTicket();
86
+ let failedAttempts = 0;
87
+ let ticketRenewals = 0;
88
+ for (;;) {
89
+ stopIfCancelled();
90
+ report("uploading");
91
+ const outcome = await this.post(issued.ticket, file, signal, (sentBytes) => report("uploading", sentBytes));
92
+ if (outcome.kind === "stored")
93
+ break;
94
+ if (outcome.kind === "cancelled")
95
+ throw new LambderUploadError("cancelled");
96
+ if (outcome.kind === "rejected") {
97
+ // An expired ticket needs no wait and costs no attempt, only a
98
+ // new ticket; any other refusal is the file's, and final.
99
+ if (!outcome.ticketExpired || ++ticketRenewals > TICKET_RENEWAL_LIMIT)
100
+ throw new LambderUploadError("storageRejected", { detail: outcome.detail });
101
+ issued = await requestTicket();
102
+ continue;
103
+ }
104
+ // The ticket is kept through a network retry, so a flaky
105
+ // connection does not leave the app a record per attempt.
106
+ if (++failedAttempts >= this.attempts)
107
+ throw new LambderUploadError("networkFailed");
108
+ await this.waitBeforeRetry(failedAttempts, signal);
109
+ }
110
+ report("confirming", file.size);
111
+ try {
112
+ return await this.options.confirmUpload(issued.reference, { signal });
113
+ }
114
+ catch (cause) {
115
+ throw new LambderUploadError(signal?.aborted ? "cancelled" : "confirmRefused", { cause });
116
+ }
117
+ }
118
+ /** Forgets a confirmed upload through the app's endpoint, when it declared one. */
119
+ async discard(receipt) {
120
+ await this.options.discardUpload?.(receipt);
121
+ }
122
+ waitBeforeRetry(failedAttempts, signal) {
123
+ const ceiling = Math.max(this.baseDelayMs, Math.min(this.baseDelayMs * 2 ** (failedAttempts - 1), this.maxDelayMs));
124
+ return new Promise((resolve, reject) => {
125
+ if (signal?.aborted)
126
+ return reject(new LambderUploadError("cancelled"));
127
+ const cancel = () => {
128
+ clearTimeout(timer);
129
+ reject(new LambderUploadError("cancelled"));
130
+ };
131
+ const timer = setTimeout(() => {
132
+ signal?.removeEventListener("abort", cancel);
133
+ resolve();
134
+ }, this.baseDelayMs + Math.random() * (ceiling - this.baseDelayMs));
135
+ signal?.addEventListener("abort", cancel, { once: true });
136
+ });
137
+ }
138
+ /** One post of the file to storage. Never throws: every ending is an outcome. */
139
+ post(ticket, file, signal, onSent) {
140
+ if (signal?.aborted)
141
+ return Promise.resolve({ kind: "cancelled" });
142
+ const form = new FormData();
143
+ for (const [name, value] of Object.entries(ticket.formFields))
144
+ form.append(name, value);
145
+ // Storage ignores every field that comes after the file.
146
+ form.append("file", file);
147
+ return typeof XMLHttpRequest === "function"
148
+ ? postWithXhr(ticket.uploadUrl, form, file.size, this.stallTimeoutMs, signal, onSent)
149
+ : postWithFetch(ticket.uploadUrl, form, file.size, signal, onSent);
150
+ }
151
+ }
152
+ /** XMLHttpRequest rather than fetch where it exists: fetch cannot report how much of a request body has been sent. */
153
+ const postWithXhr = (url, form, fileBytes, stallTimeoutMs, signal, onSent) => new Promise((resolve) => {
154
+ const request = new XMLHttpRequest();
155
+ let stalled = false;
156
+ let stallTimer;
157
+ const watchForStall = () => {
158
+ clearTimeout(stallTimer);
159
+ stallTimer = setTimeout(() => {
160
+ stalled = true;
161
+ request.abort();
162
+ }, stallTimeoutMs);
163
+ };
164
+ const cancel = () => request.abort();
165
+ const settle = (outcome) => {
166
+ clearTimeout(stallTimer);
167
+ signal?.removeEventListener("abort", cancel);
168
+ resolve(outcome);
169
+ };
170
+ request.upload.onprogress = (event) => {
171
+ watchForStall();
172
+ // `loaded` counts the form's own framing too, a little over the file.
173
+ onSent(Math.min(event.loaded, fileBytes));
174
+ };
175
+ request.onload = () => {
176
+ if (request.status >= 200 && request.status < 300)
177
+ return settle({ kind: "stored" });
178
+ if (request.status >= 500)
179
+ return settle({ kind: "unreachable" });
180
+ settle(rejectedOutcome(request.status, request.responseText));
181
+ };
182
+ request.onerror = () => settle({ kind: "unreachable" });
183
+ request.onabort = () => settle(stalled ? { kind: "unreachable" } : { kind: "cancelled" });
184
+ signal?.addEventListener("abort", cancel, { once: true });
185
+ watchForStall();
186
+ try {
187
+ request.open("POST", url);
188
+ request.send(form);
189
+ }
190
+ catch {
191
+ // A URL the browser will not open, or a request it will not send, answers nothing.
192
+ settle({ kind: "unreachable" });
193
+ }
194
+ });
195
+ const postWithFetch = async (url, form, fileBytes, signal, onSent) => {
196
+ let response;
197
+ try {
198
+ response = await fetch(url, { method: "POST", body: form, signal });
199
+ }
200
+ catch {
201
+ return signal?.aborted ? { kind: "cancelled" } : { kind: "unreachable" };
202
+ }
203
+ if (response.ok) {
204
+ onSent(fileBytes);
205
+ return { kind: "stored" };
206
+ }
207
+ if (response.status >= 500)
208
+ return { kind: "unreachable" };
209
+ return rejectedOutcome(response.status, await response.text().catch(() => ""));
210
+ };
211
+ const XML_ENTITIES = { amp: "&", lt: "<", gt: ">", quot: "\"", apos: "'" };
212
+ const xmlText = (text) => text.replace(/&(#x[0-9a-f]+|#\d+|\w+);/gi, (entity, name) => {
213
+ if (name[0] !== "#")
214
+ return XML_ENTITIES[name] ?? entity;
215
+ const codePoint = Number(name[1]?.toLowerCase() === "x" ? `0${name.slice(1)}` : name.slice(1));
216
+ return codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : entity;
217
+ });
218
+ /**
219
+ * Storage explains a refusal in XML, `<Error><Code/><Message/></Error>`,
220
+ * read here without a DOM so the runner works wherever fetch does. A
221
+ * transient code is tried again like a dropped connection, and an expired
222
+ * ticket is the one refusal a new ticket cures.
223
+ */
224
+ const rejectedOutcome = (status, body) => {
225
+ const code = xmlText(/<Code>([^<]*)<\/Code>/.exec(body)?.[1] ?? "") || `HTTP ${status}`;
226
+ const message = xmlText(/<Message>([^<]*)<\/Message>/.exec(body)?.[1] ?? "");
227
+ if (TRANSIENT_STORAGE_CODES.has(code))
228
+ return { kind: "unreachable" };
229
+ return {
230
+ kind: "rejected",
231
+ ticketExpired: code === "AccessDenied" && /expired/i.test(message),
232
+ detail: message ? `${code}: ${message}` : code,
233
+ };
234
+ };
@@ -2,7 +2,10 @@ import type { LambderApiTransport } from "../shared/transport/LambderApiTranspor
2
2
  /**
3
3
  * The production transport: one POST of the envelope to the API path over
4
4
  * fetch, with the cookie and CORS behaviour a browser call needs. The
5
- * caller's default, built from its isCorsEnabled option.
5
+ * caller's default, built from its isCorsEnabled option. `cors` defaults to
6
+ * whether the call's apiPath is on another origin than the page's: a browser
7
+ * refuses a same-origin mode request to another origin outright, and a
8
+ * credentialed cross-origin one is what a separate API host needs.
6
9
  */
7
10
  export declare const lambderFetchTransport: (options?: {
8
11
  cors?: boolean;
@@ -1,20 +1,19 @@
1
1
  import { buildTransportEnvelope, LambderTransportFailure } from "../shared/transport/LambderApiTransport.js";
2
2
  /**
3
- * fetch, with one failure worth a better sentence than the platform gives it.
4
- * A relative apiPath is resolved against the page; outside a page there is no
5
- * page to resolve it against, so fetch rejects with a URL parse error that
6
- * arrives at the caller looking like the network is down. The condition is
7
- * read from the actual failure rather than guessed beforehand, so a runtime
8
- * that resolves relative URLs some other way is left alone.
3
+ * fetch, with a clearer error for one failure. Outside a page a relative
4
+ * apiPath has nothing to resolve against, so fetch rejects with a URL parse
5
+ * error that reaches the caller looking like the network is down. The
6
+ * condition is read from the actual failure rather than guessed beforehand,
7
+ * so a runtime that resolves relative URLs some other way is left alone.
9
8
  */
10
9
  const fetchOrExplain = async (url, init) => {
11
10
  try {
12
11
  return await fetch(url, init);
13
12
  }
14
13
  catch (err) {
15
- // Narrow on purpose: only the URL failing to parse, which is what
16
- // this is. A transport that rejected for any other reason, a stubbed
17
- // one included, keeps its own meaning.
14
+ // Narrow on purpose: only a URL parse failure is rewritten. A fetch
15
+ // that rejected for any other reason, a stubbed one included, keeps
16
+ // its own error.
18
17
  const failedToParseUrl = err instanceof TypeError && /failed to parse url|invalid url/i.test(String(err.message));
19
18
  if (failedToParseUrl && url.startsWith("/") && typeof globalThis.location?.href !== "string") {
20
19
  throw new LambderTransportFailure("protocol", `lambderFetchTransport could not resolve the relative apiPath "${url}": there is no page to resolve it against outside a browser. `
@@ -26,10 +25,32 @@ const fetchOrExplain = async (url, init) => {
26
25
  /**
27
26
  * The production transport: one POST of the envelope to the API path over
28
27
  * fetch, with the cookie and CORS behaviour a browser call needs. The
29
- * caller's default, built from its isCorsEnabled option.
28
+ * caller's default, built from its isCorsEnabled option. `cors` defaults to
29
+ * whether the call's apiPath is on another origin than the page's: a browser
30
+ * refuses a same-origin mode request to another origin outright, and a
31
+ * credentialed cross-origin one is what a separate API host needs.
30
32
  */
31
33
  export const lambderFetchTransport = (options = {}) => async (request) => {
32
- const cors = options.cors ?? false;
34
+ let cors = options.cors;
35
+ if (cors === undefined) {
36
+ // Resolved against the page, so a protocol-relative `//api.example.com`
37
+ // counts as another origin too. Outside a page a relative path
38
+ // resolves against nothing and an absolute one has no page origin to
39
+ // match, and neither matters there: a fetch outside a browser does
40
+ // not enforce the mode.
41
+ try {
42
+ cors = new URL(request.apiPath, globalThis.location?.href).origin !== globalThis.location?.origin;
43
+ }
44
+ catch {
45
+ cors = false;
46
+ }
47
+ }
48
+ // The headers this transport sets itself leave the caller's set in any
49
+ // spelling: fetch merges header names case-insensitively, so a
50
+ // `content-type` beside the transport's `Content-Type` would join it
51
+ // ("text/plain, application/json") rather than give way to it.
52
+ const owned = new Set(['content-type', ...(request.cookies?.length ? ['cookie'] : [])]);
53
+ const callerHeaders = Object.fromEntries(Object.entries(request.headers ?? {}).filter(([name]) => !owned.has(name.toLowerCase())));
33
54
  const response = await fetchOrExplain(request.apiPath, {
34
55
  method: 'POST', cache: 'no-cache',
35
56
  // Cross-origin API hosts need CORS mode and included credentials.
@@ -37,33 +58,36 @@ export const lambderFetchTransport = (options = {}) => async (request) => {
37
58
  credentials: cors ? 'include' : 'same-origin',
38
59
  redirect: 'follow', referrerPolicy: 'origin',
39
60
  headers: {
40
- // The caller's own headers go on first, so the ones this transport
41
- // owns cannot be displaced by them, the way the synthesized invoke
42
- // event does it. With the spread last, a per-call `Cookie` header
43
- // (to carry one extra cookie, say) replaced the whole Cookie
44
- // header a jar had just built, and the session went missing with
45
- // nothing in the failure pointing at the cause.
46
- ...(request.headers ?? {}),
61
+ // The caller's own headers go on first so they cannot displace the
62
+ // ones this transport owns (the synthesized invoke event does the
63
+ // same). With the spread last, a per-call `Cookie` header would
64
+ // replace the whole Cookie header a jar had just built, and the
65
+ // session would go missing with nothing pointing at the cause.
66
+ ...callerHeaders,
47
67
  'Content-Type': 'application/json',
48
- // The cookies the request is meant to carry, which is how a cookie
49
- // jar works over fetch outside a browser: undici sends this
50
- // header, and a page cannot (Cookie is a forbidden header name, so
51
- // a browser drops it silently and uses its own cookie store, which
52
- // is the right answer there). Without it the jar collected every
53
- // Set-Cookie and sent none of them back, so a Node script against
54
- // a deployed app got its CSRF token filled in and sessionExpired
55
- // on every session call.
68
+ // The cookies the request carries: this is how a cookie jar works
69
+ // over fetch outside a browser, where undici sends the header. A
70
+ // page cannot (Cookie is a forbidden header name, so a browser
71
+ // drops it and uses its own cookie store, which is right there).
72
+ // Without it a jar would collect every Set-Cookie and send none
73
+ // back, and a Node script against a deployed app would get
74
+ // sessionExpired on every session call.
56
75
  ...(request.cookies?.length ? { Cookie: request.cookies.join('; ') } : {}),
57
76
  },
58
77
  body: JSON.stringify(buildTransportEnvelope(request)),
59
78
  ...(request.signal ? { signal: request.signal } : {}),
60
79
  });
80
+ // Read here, inside the call, so the caller's timeout and abort cover the
81
+ // whole answer: fetch resolves on the headers, and a body abandoned while
82
+ // it downloads would otherwise read as a malformed answer (a server
83
+ // error) rather than the timeout or abort it was.
84
+ const body = await response.text();
61
85
  return {
62
86
  status: response.status,
63
87
  statusText: response.statusText,
64
88
  header: (name) => response.headers?.get?.(name) ?? null,
65
- json: () => response.json(),
66
- text: () => response.text(),
89
+ json: async () => JSON.parse(body),
90
+ text: async () => body,
67
91
  // Set-Cookie is unreadable from a page's script; where the runtime
68
92
  // exposes it (Node's fetch), a cookie jar can still consume it.
69
93
  ...(typeof response.headers?.getSetCookie === "function" ? { setCookies: response.headers.getSetCookie() } : {}),